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

+3 -3
View File
@@ -41,11 +41,11 @@ describe('автораспределение заказов', () => {
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Ani' });
const second = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, status: 'OFFERED' } });
await respondToOffer(ctx, { offerId: second.id, providerId: fx.ani.id, accept: false });
// больше свободных частных нет — компания включается и получает заказ
// больше свободных частных нет; партнёрам система сама не назначает — это делает оператор
await db.providerCompany.update({ where: { id: fx.company.id }, data: { active: true } });
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Chisto' });
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'none' });
const offers = await db.orderOffer.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
expect(offers.map((x) => x.providerId ?? x.companyId)).toEqual([fx.maria.id, fx.ani.id, fx.company.id]);
expect(offers.map((x) => x.providerId ?? x.companyId)).toEqual([fx.maria.id, fx.ani.id]);
});
it('снятый с заказа исполнитель не получает его снова автоматически', async () => {
+6 -4
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { encodeStartPayload } from '@barev/core';
import { addMonths, encodeStartPayload } from '@barev/core';
import {
applyAgencyCode,
assignmentCandidates,
@@ -95,7 +95,7 @@ describe('онбординг (ONB-1…6)', () => {
expect(client.user.locale).toBe('hy');
const gift = await giveConsent(ctx, start.clientId);
expect(gift?.endsAt.getTime()).toBe(NOW.getTime() + 30 * 24 * 3600_000);
expect(gift?.endsAt.getTime()).toBe(addMonths(NOW, 1).getTime());
expect(events).toContainEqual(expect.objectContaining({ type: 'gift.activated' }));
expect(await db.subscription.count({ where: { clientId: start.clientId, source: 'AGENCY_GIFT' } })).toBe(1);
});
@@ -388,9 +388,11 @@ describe('выплаты клинеру по пресету', () => {
expect(orderPayout(full, maria)).toBe(9000 + 2500 + 1500 + 2000);
// без пресета — индивидуальная ставка 70% от итога
expect(orderPayout(full, { ...maria, payoutPreset: null })).toBe(Math.floor(full.totalPrice * 0.7));
// сотрудник компании — ставка компании
// сотрудник партнёра «как Uber» — ему платит партнёр, от нас 0
const lusine = await db.provider.findUniqueOrThrow({ where: { id: fx.lusine.id }, include: { company: true, payoutPreset: true } });
expect(orderPayout(full, lusine)).toBe(Math.floor(full.totalPrice * 0.65));
expect(orderPayout(full, lusine)).toBe(0);
// «расходы наши» — ФОТ клинера, % от оплаченного
expect(orderPayout(full, { ...lusine, company: { deal: 'PROFIT_SHARE', fotPct: 40 } })).toBe(Math.floor(full.totalPrice * 0.4));
});
});
+14
View File
@@ -49,6 +49,20 @@ export async function fixtures() {
],
});
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
// справочники бариков (в базе их создаёт миграция)
await db.pointsCategory.createMany({
data: [
{ code: 'cleaning', name: { ru: 'Клининг' }, rateBarev: 5, ratePlus: 8, rateElite: 10 },
{ code: 'documents', name: { ru: 'Документы' }, rateBarev: 5, ratePlus: 10, rateElite: 12 },
],
});
await db.pointsBonusType.createMany({
data: [
{ code: 'housewarming', name: { ru: 'Новоселье' }, defaultAmount: 5000, ttlDays: 60 },
{ code: 'promo', name: { ru: 'Промо' }, defaultAmount: 1000, ttlDays: 30 },
{ code: 'first_month_route', name: { ru: 'Маршрут' }, defaultAmount: 3000 },
],
});
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
const home = await db.category.create({ data: { code: 'HOME', barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS.HOME } });
const c = CATALOG.find((s) => s.code === 'cleaning')!;
+70
View File
@@ -0,0 +1,70 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { createOrder, editOrderComposition, giveConsent, markPaid, previewOrderEdit, staffChecklistItem, startClient, transitionOrder } from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
});
afterAll(() => db.$disconnect());
async function order(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id });
}
const admin = () => db.adminUser.create({ data: { name: 'Op', roles: ['SUPPORT'] } });
describe('команда меняет заказ', () => {
it('состав: пересчёт цены и длительности, история, уведомление по галочке', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await order(ctx, 1501);
const op = await admin();
const p = await previewOrderEdit(ctx, { orderId: o.id, params: { cleaningType: 'regular', rooms: 3, bathrooms: 1, extras: { windows: 2 } } });
expect(p.totalPrice).toBeGreaterThan(o.totalPrice);
expect(p.durationMinutes).toBeGreaterThan(o.durationMinutes);
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: p.params, notify: true });
const after = await db.order.findUniqueOrThrow({ where: { id: o.id } });
expect(after).toMatchObject({ totalPrice: p.totalPrice, durationMinutes: p.durationMinutes });
expect((after.params as Record<string, unknown>).rooms).toBe(3);
expect(events).toContainEqual({ type: 'order.edited', orderId: o.id });
expect(await db.orderStatusHistory.count({ where: { orderId: o.id, event: 'editedByStaff' } })).toBe(1);
});
it('своя цена — только с причиной', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 1502);
const op = await admin();
await expect(editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, totalPrice: 12_345 })).rejects.toMatchObject({ code: 'REASON_REQUIRED' });
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, totalPrice: 12_345, reason: 'договорились' });
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).totalPrice).toBe(12_345);
});
it('после оплаты: кэшбек пересчитывается по новой цене', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 1503);
const op = await admin();
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
await db.orderChecklistItem.updateMany({ where: { orderId: o.id }, data: { checkedAt: NOW, requiresPhoto: false } });
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
await markPaid(ctx, { orderId: o.id, by: { type: 'ADMIN', id: op.id } });
const before = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id, status: { not: 'REVERSED' } } });
await editOrderComposition(ctx, { orderId: o.id, adminId: op.id, params: { cleaningType: 'regular', rooms: 4, bathrooms: 2 } });
const now = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id, status: { not: 'REVERSED' } } });
expect(now.id).not.toBe(before.id);
expect(now.amount).toBeGreaterThan(before.amount);
});
it('чек-лист за клинера: пункт с фото — без фото только с причиной; потом заказ можно закрыть', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 1504);
const op = await admin();
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
const items = await db.orderChecklistItem.findMany({ where: { orderId: o.id } });
const photo = items.find((i) => i.requiresPhoto)!;
await expect(staffChecklistItem(ctx, { orderId: o.id, adminId: op.id, code: photo.code, checked: true })).rejects.toMatchObject({ code: 'PHOTO_REQUIRED' });
for (const i of items) await staffChecklistItem(ctx, { orderId: o.id, adminId: op.id, code: i.code, checked: true, reason: i.requiresPhoto ? 'фото в чате' : null });
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).status).toBe('DONE');
});
});
+193
View File
@@ -0,0 +1,193 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { addMonths } from '@barev/core';
import {
applyAgencyCode,
assignmentCandidates,
companyAutoAssign,
completeWebRequest,
createOrder,
createWebRequest,
dispatcherAssign,
expireOffers,
giveConsent,
linkWebRequest,
markPaid,
offerOrder,
partnerAddStaff,
partnerCabinetData,
partnerDeclineOrder,
partnerUnassign,
respondToOffer,
startClient,
} from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
});
afterAll(() => db.$disconnect());
type C = ReturnType<typeof makeCtx>['ctx'];
async function order(ctx: C, tg: number, at = slotAt(2, 11), allowBusySlot = false) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: at, addressText: 'Pushkin 10', districtId: fx.kentron.id, allowBusySlot });
}
const admin = () => db.adminUser.create({ data: { name: 'Op', roles: ['SUPPORT'] } });
const events = async (orderId: string) => (await db.orderStatusHistory.findMany({ where: { orderId }, orderBy: { createdAt: 'asc' } })).map((h) => h.event);
describe('партнёры услуг: оператор назначает', () => {
it('у каждого варианта видно, что остаётся нам; самый выгодный подсвечен', async () => {
const { ctx } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { deal: 'COMMISSION', commissionPct: 40 } });
const o = await order(ctx, 901);
const r = await assignmentCandidates(ctx, o.id);
const chisto = r.ranked.find((c) => c.kind === 'company')!;
expect(chisto.margin).toBe(Math.floor(o.totalPrice * 0.4));
const maria = r.ranked.find((c) => c.name === 'Maria')!;
expect(maria.margin).toBe(o.totalPrice - maria.cost);
const best = [...r.ranked].sort((a, b) => b.margin - a.margin)[0]!;
expect(r.bestId).toBe(`${best.kind}:${best.id}`);
});
it('партнёр отказался — заказ снова у оператора', async () => {
const { ctx, events: ev } = makeCtx(NOW);
const o = await order(ctx, 902);
const op = await admin();
await offerOrder(ctx, { orderId: o.id, adminId: op.id, target: { kind: 'company', id: fx.company.id } });
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, companyId: fx.company.id, status: 'OFFERED' } });
expect(offer.expiresAt).not.toBeNull();
await partnerDeclineOrder(ctx, { orderId: o.id, companyId: fx.company.id, userId: fx.dispatcher.id });
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).companyId).toBeNull();
expect(ev).toContainEqual({ type: 'partner.returned', orderId: o.id, companyId: fx.company.id, reason: 'declined' });
});
it('партнёр не назначил сотрудника вовремя — заказ возвращается оператору', async () => {
const { ctx, clock } = makeCtx(NOW);
const o = await order(ctx, 903);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
clock.now = new Date(NOW.getTime() + 61 * 60_000);
await expireOffers(ctx);
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).companyId).toBeNull();
expect(await events(o.id)).toContain('partnerExpired');
});
});
describe('партнёры услуг: автораспределение по сотрудникам', () => {
it('заказ сам уходит сотруднику; отказ — следующему; никого нет — менеджеру', async () => {
const { ctx, events: ev } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { autoAssign: true } });
const added = await partnerAddStaff(ctx, {
companyId: fx.company.id,
userId: fx.dispatcher.id,
staff: { name: 'Gayane', languages: ['hy'], districtIds: [fx.kentron.id], schedule: [0, 1, 2, 3, 4, 5, 6].map((weekday) => ({ weekday, startMinute: 540, endMinute: 1200 })) },
});
// сотрудник ещё не вошёл в бот — автоматически ему не предлагаем
expect(added.inviteCode).toMatch(/^PRO-/);
const o = await order(ctx, 904);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
const first = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, status: 'OFFERED', providerId: { not: null } } });
expect(first.providerId).toBe(fx.lusine.id);
await respondToOffer(ctx, { offerId: first.id, providerId: fx.lusine.id, accept: false });
// больше подходящих нет — заказ ждёт менеджера
expect(await db.orderOffer.count({ where: { orderId: o.id, status: 'OFFERED', providerId: null, companyId: fx.company.id } })).toBe(1);
expect(ev).toContainEqual({ type: 'company.noStaff', orderId: o.id, companyId: fx.company.id });
expect(await companyAutoAssign(ctx, o.id)).toEqual({ result: 'none' });
});
it('лимит заказов в день и перерыв на дорогу', async () => {
const { ctx } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { autoAssign: true, travelGapMinutes: 60 } });
// первый заказ Лусине: 11:00, уборка 2 комнат ~3 часа
const a = await order(ctx, 905, slotAt(2, 11));
await offerOrder(ctx, { orderId: a.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
const offerA = await db.orderOffer.findFirstOrThrow({ where: { orderId: a.id, status: 'OFFERED', providerId: { not: null } } });
await respondToOffer(ctx, { offerId: offerA.id, providerId: fx.lusine.id, accept: true });
const end = new Date(a.scheduledAt.getTime() + a.durationMinutes * 60_000);
// сразу после окончания — нет времени доехать
const b = await order(ctx, 906, new Date(end.getTime() + 30 * 60_000), true);
const cab = await import('../src/servicePartners.ts');
const settings = await (await import('../src/settings.ts')).getSettings(db);
const tight = await cab.companyStaffCandidates(db, fx.company.id, b.id, settings);
expect(tight.rows.find((r) => r.id === fx.lusine.id)?.check).toBe('gap');
// лимит 1 заказ в день
await db.provider.update({ where: { id: fx.lusine.id }, data: { maxOrdersPerDay: 1 } });
const c = await order(ctx, 907, new Date(end.getTime() + 2 * 3600_000), true);
const limited = await cab.companyStaffCandidates(db, fx.company.id, c.id, settings);
expect(limited.rows.find((r) => r.id === fx.lusine.id)?.check).toBe('limit');
});
it('менеджер назначает вручную и снимает сотрудника', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 908);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
await dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: fx.dispatcher.id });
expect((await db.order.findUniqueOrThrow({ where: { id: o.id } })).providerId).toBe(fx.lusine.id);
await partnerUnassign(ctx, { orderId: o.id, companyId: fx.company.id, userId: fx.dispatcher.id });
const after = await db.order.findUniqueOrThrow({ where: { id: o.id } });
expect(after).toMatchObject({ providerId: null, companyId: fx.company.id, status: 'NEW' });
const cab = await partnerCabinetData(ctx, fx.company.id, fx.dispatcher.id);
expect(cab.pending.map((x) => x.id)).toContain(o.id);
});
});
describe('партнёры услуг: доход', () => {
it('«расходы наши»: при оплате фиксируется доля партнёра от прибыли', async () => {
const { ctx } = makeCtx(NOW);
await db.providerCompany.update({ where: { id: fx.company.id }, data: { deal: 'PROFIT_SHARE', profitSharePct: 30, fotPct: 60, taxPct: 5 } });
const o = await order(ctx, 910);
await offerOrder(ctx, { orderId: o.id, adminId: (await admin()).id, target: { kind: 'company', id: fx.company.id } });
await dispatcherAssign(ctx, { orderId: o.id, companyId: fx.company.id, providerId: fx.lusine.id, dispatcherUserId: fx.dispatcher.id });
await markPaid(ctx, { orderId: o.id, by: { type: 'PROVIDER', id: fx.lusine.id } });
const e = await db.partnerEarning.findUniqueOrThrow({ where: { orderId: o.id } });
const fot = Math.floor(o.totalPrice * 0.6);
const tax = Math.floor(o.totalPrice * 0.05);
expect(e).toMatchObject({ deal: 'PROFIT_SHARE', paid: o.totalPrice, fot, tax, amount: Math.floor(((o.totalPrice - fot - tax) * 30) / 100) });
const cab = await partnerCabinetData(ctx, fx.company.id, fx.dispatcher.id);
expect(cab.money.balance).toBe(e.amount);
});
it('чужой менеджер не видит кабинет', async () => {
const { ctx } = makeCtx(NOW);
await expect(partnerCabinetData(ctx, fx.company.id, (await db.user.create({ data: { telegramId: 99999n } })).id)).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
});
describe('подарок агентства', () => {
it('уровень и срок задаются у агентства', async () => {
const { ctx } = makeCtx(NOW);
await db.agency.update({ where: { id: fx.agency.id }, data: { giftPlan: 'ELITE', giftMonths: 3 } });
const s = await startClient(ctx, tgUser(920));
await giveConsent(ctx, s.clientId);
const r = await applyAgencyCode(ctx, s.clientId, 'DOMIK');
expect(r.gift).toEqual({ plan: 'ELITE', endsAt: addMonths(NOW, 3) });
const sub = await db.subscription.findFirstOrThrow({ where: { clientId: s.clientId } });
expect(sub).toMatchObject({ plan: 'ELITE', agencyId: fx.agency.id, source: 'AGENCY_GIFT' });
});
it('агентство без подарка', async () => {
const { ctx } = makeCtx(NOW);
await db.agency.update({ where: { id: fx.agency.id }, data: { giftPlan: null } });
const s = await startClient(ctx, tgUser(921));
await giveConsent(ctx, s.clientId);
expect((await applyAgencyCode(ctx, s.clientId, 'DOMIK')).gift).toBeNull();
});
});
describe('заявка с сайта без времени и адреса', () => {
it('бот доспрашивает недостающее и оформляет заказ', async () => {
const { ctx } = makeCtx(NOW);
const req = await createWebRequest(ctx, { serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2, bathrooms: 1 }, phone: '+37491123456', locale: 'ru' });
expect(req).toMatchObject({ preferredAt: null, districtId: null, addressText: null });
const s = await startClient(ctx, tgUser(930));
const link = await linkWebRequest(ctx, req.token, s.clientId);
expect(link).toMatchObject({ kind: 'incomplete', missing: ['district', 'address', 'time'] });
if (link.kind !== 'incomplete') return;
expect(await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, districtCode: 'kentron' })).toMatchObject({ kind: 'incomplete', missing: ['address', 'time'] });
expect(await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, addressText: 'Abovyan 5' })).toMatchObject({ kind: 'incomplete', missing: ['time'] });
const done = await completeWebRequest(ctx, { requestId: link.requestId, clientId: s.clientId, preferredAt: slotAt(2, 12) });
expect(done).toMatchObject({ kind: 'ordered' });
expect((await db.webRequest.findUniqueOrThrow({ where: { id: req.id } })).status).toBe('ORDERED');
});
});
+212
View File
@@ -0,0 +1,212 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import {
accruePoints,
activatePendingPoints,
burnPoints,
cancelByClient,
confirmRedemption,
createOrder,
giveConsent,
markPaid,
reconcilePoints,
redeemPoints,
requestRedemption,
reverseAccrual,
reversePreview,
reverseRedemption,
startClient,
transitionOrder,
warnExpiringPoints,
applyAgencyCode,
} from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
});
afterAll(() => db.$disconnect());
type C = ReturnType<typeof makeCtx>['ctx'];
const OWNER = { adminId: null, owner: true };
const H = 3600_000;
const D = 24 * H;
async function client(ctx: C, tg: number) {
const s = await startClient(ctx, tgUser(tg));
await giveConsent(ctx, s.clientId);
return s.clientId;
}
const cat = (code = 'cleaning') => db.pointsCategory.findUniqueOrThrow({ where: { code } });
const bal = async (id: string) => db.client.findUniqueOrThrow({ where: { id }, select: { pointsActive: true, pointsPending: true, pointsDebt: true } });
async function give(ctx: C, clientId: string, amount: number, extra: Record<string, unknown> = {}) {
return accruePoints(ctx, { clientId, kind: 'ORDER', categoryId: (await cat()).id, orderAmount: 100_000, amount, reason: 'тест', serviceDate: NOW, noWait: true, channel: 'WEB', ...extra }, OWNER);
}
describe('барики: начисление', () => {
it('по заказу: подсказка по ставке Barev, ожидание 48 ч, потом доступны', async () => {
const { ctx, clock, events } = makeCtx(NOW);
const id = await client(ctx, 1001);
const r = await accruePoints(ctx, { clientId: id, kind: 'ORDER', categoryId: (await cat()).id, orderAmount: 18_000, serviceDate: NOW, channel: 'WEB', orderRef: '1042' }, OWNER);
expect(r).toMatchObject({ status: 'created', amount: 900 });
expect(await bal(id)).toMatchObject({ pointsActive: 0, pointsPending: 900 });
expect(events.some((e) => e.type === 'points.accrued')).toBe(true);
clock.now = new Date(NOW.getTime() + 49 * H);
expect(await activatePendingPoints(ctx)).toBe(1);
expect(await bal(id)).toMatchObject({ pointsActive: 900, pointsPending: 0 });
});
it('тот же номер заказа второй раз — нельзя; изменённая сумма — только с причиной', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1002);
const base = { clientId: id, kind: 'ORDER' as const, categoryId: (await cat()).id, orderAmount: 18_000, serviceDate: NOW, channel: 'WEB' as const };
await expect(accruePoints(ctx, { ...base, amount: 2000, orderRef: 'A1' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_REASON_REQUIRED' });
await accruePoints(ctx, { ...base, orderRef: 'A1' }, OWNER);
await expect(accruePoints(ctx, { ...base, orderRef: 'A1' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_DUPLICATE_ORDER' });
});
it('бонус со своим сроком; «без ожидания» у поддержки — только с причиной и в пределах лимитов', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1003);
const promo = await db.pointsBonusType.findUniqueOrThrow({ where: { code: 'promo' } });
await accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, serviceDate: NOW, channel: 'WEB' }, OWNER);
const a = await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } });
expect(a).toMatchObject({ status: 'ACTIVE', amount: 1000 });
expect(a.expiresAt?.getTime()).toBe(NOW.getTime() + 30 * D);
// поддержка без разрешения в настройках — нельзя
const support = { adminId: 'op1', owner: false };
await expect(give(ctx, id, 100, { orderRef: 'S1' }).then(() => accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, serviceDate: NOW, channel: 'WEB' }, support))).rejects.toMatchObject({ code: 'FORBIDDEN' });
await db.setting.create({ data: { key: 'points.supportCanManage', value: true } });
const big = await accruePoints(ctx, { clientId: id, kind: 'BONUS', bonusTypeId: promo.id, amount: 25_000, serviceDate: NOW, channel: 'WEB' }, support);
expect(big.status).toBe('needs_approval');
});
it('серия уборок: третья подряд — +2 п.п. к ставке', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1004);
const c = await cat();
for (const [i, ref] of ['s1', 's2', 's3'].entries()) {
await accruePoints(ctx, { clientId: id, kind: 'ORDER', categoryId: c.id, orderAmount: 10_000, serviceDate: new Date(NOW.getTime() - (40 - i * 20) * D), channel: 'WEB', orderRef: ref }, OWNER);
}
const last = await db.pointsAccrual.findFirstOrThrow({ where: { orderRef: 's3' } });
expect(last).toMatchObject({ ratePct: 7, streakBonusPct: 2, amount: 700 });
});
});
describe('барики: списание', () => {
it('FIFO, лимит 30% и минимум 1000; два списания одновременно не уводят в минус', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1101);
await give(ctx, id, 3000, { orderRef: 'o1', serviceDate: new Date(NOW.getTime() - 10 * D) });
await give(ctx, id, 2000, { orderRef: 'o2' });
await expect(redeemPoints(ctx, { clientId: id, amount: 500, channel: 'WEB', reason: 'x' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_BELOW_MIN' });
await expect(redeemPoints(ctx, { clientId: id, amount: 4000, orderAmount: 10_000, channel: 'WEB', reason: 'x' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_OVER_SHARE' });
const results = await Promise.allSettled([
redeemPoints(ctx, { clientId: id, amount: 3500, channel: 'WEB', reason: 'a' }, OWNER),
redeemPoints(ctx, { clientId: id, amount: 3500, channel: 'WEB', reason: 'b' }, OWNER),
]);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect((await bal(id)).pointsActive).toBe(1500);
const red = await db.pointsRedemption.findFirstOrThrow({ where: { clientId: id, status: 'CONFIRMED' }, include: { items: { include: { accrual: true } } } });
expect(red.items.map((i) => [i.accrual.orderRef, i.amount])).toEqual([
['o1', 3000],
['o2', 500],
]);
});
it('запрос клиента → команда подтверждает по сумме заказа', async () => {
const { ctx, events } = makeCtx(NOW);
const id = await client(ctx, 1102);
await give(ctx, id, 10_000, { orderRef: 'o1' });
const r = await requestRedemption(ctx, { clientId: id, amount: 8000 });
expect(events).toContainEqual({ type: 'points.requested', redemptionId: r.id });
const res = await confirmRedemption(ctx, { redemptionId: r.id, orderAmount: 18_000 }, OWNER);
expect(res.amount).toBe(5400);
expect((await bal(id)).pointsActive).toBe(4600);
});
it('в заказе: барики уменьшают сумму к оплате, кэшбек — с оплаченного деньгами; отмена всё возвращает', async () => {
const { ctx, clock } = makeCtx(NOW);
const id = await client(ctx, 1103);
await give(ctx, id, 5000, { orderRef: 'o1' });
const o = await createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 3000 });
expect(o.pointsUsed).toBe(3000);
expect((await bal(id)).pointsActive).toBe(2000);
await expect(createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(3, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 1000, promoCode: 'X' })).rejects.toBeDefined();
// выполнен и оплачен — кэшбек с (цена − барики)
await db.order.update({ where: { id: o.id }, data: { status: 'CLEANING', providerId: fx.maria.id } });
await db.orderChecklistItem.updateMany({ where: { orderId: o.id }, data: { checkedAt: NOW, requiresPhoto: false } });
await markPaid(ctx, { orderId: o.id, by: { type: 'PROVIDER', id: fx.maria.id } });
clock.now = new Date(slotAt(2, 14).getTime());
await transitionOrder(ctx, { orderId: o.id, to: 'DONE', actor: 'ADMIN' });
const acc = await db.pointsAccrual.findFirstOrThrow({ where: { orderId: o.id } });
expect(acc).toMatchObject({ channel: 'AUTO', paidWithPoints: 3000, amount: Math.floor(((o.totalPrice - 3000) * 5) / 100), status: 'PENDING' });
const pay = await db.payment.findFirstOrThrow({ where: { orderId: o.id } });
expect(pay.amount).toBe(o.totalPrice - 3000);
});
it('отмена заказа клиентом возвращает списанные барики', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1104);
await give(ctx, id, 5000, { orderRef: 'o1' });
const o = await createOrder(ctx, { clientId: id, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(3, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id, usePoints: 2000 });
expect((await bal(id)).pointsActive).toBe(3000);
await cancelByClient(ctx, { orderId: o.id, clientId: id });
expect((await bal(id)).pointsActive).toBe(5000);
expect(await db.pointsReversal.count({ where: { targetType: 'REDEMPTION' } })).toBe(1);
});
});
describe('барики: отмена и сгорание', () => {
it('отмена частично потраченного — минус с предупреждением, гасится следующим начислением', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1201);
const a = await give(ctx, id, 3000, { orderRef: 'o1' });
await redeemPoints(ctx, { clientId: id, amount: 2000, channel: 'WEB', reason: 'звонок' }, OWNER);
if (a.status !== 'created') throw new Error();
expect(await reversePreview(db, { type: 'ACCRUAL', id: a.accrualId })).toMatchObject({ spent: 2000, balanceAfter: -2000 });
await reverseAccrual(ctx, { accrualId: a.accrualId, reason: 'возврат', channel: 'WEB' }, OWNER);
expect(await bal(id)).toMatchObject({ pointsActive: -2000, pointsDebt: 2000 });
const orig = await db.pointsAccrual.findUniqueOrThrow({ where: { id: a.accrualId } });
expect(orig).toMatchObject({ status: 'REVERSED', amount: 3000 });
await give(ctx, id, 2500, { orderRef: 'o2' });
expect(await bal(id)).toMatchObject({ pointsActive: 500, pointsDebt: 0 });
await expect(reverseAccrual(ctx, { accrualId: a.accrualId, reason: 'ещё', channel: 'WEB' }, OWNER)).rejects.toMatchObject({ code: 'POINTS_FINAL' });
});
it('отмена списания возвращает барики в исходные начисления', async () => {
const { ctx } = makeCtx(NOW);
const id = await client(ctx, 1202);
await give(ctx, id, 3000, { orderRef: 'o1' });
const r = await redeemPoints(ctx, { clientId: id, amount: 3000, channel: 'WEB', reason: 'x' }, OWNER);
expect((await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } })).status).toBe('SPENT');
await reverseRedemption(ctx, { redemptionId: r.redemptionId!, reason: 'ошибка', channel: 'WEB' }, OWNER);
expect(await db.pointsAccrual.findFirstOrThrow({ where: { clientId: id } })).toMatchObject({ status: 'ACTIVE', remaining: 3000 });
});
it('сгорание через 120 дней без активности, предупреждения за 14 и 3 дня', async () => {
const { ctx, clock, events } = makeCtx(NOW);
const id = await client(ctx, 1203);
await give(ctx, id, 3250, { orderRef: 'o1' });
clock.now = new Date(NOW.getTime() + 107 * D);
await warnExpiringPoints(ctx);
expect(events).toContainEqual({ type: 'points.expiring', clientId: id, amount: 3250, days: 13 });
clock.now = new Date(NOW.getTime() + 118 * D);
await warnExpiringPoints(ctx);
await warnExpiringPoints(ctx);
expect(events.filter((e) => e.type === 'points.expiring')).toHaveLength(2);
clock.now = new Date(NOW.getTime() + 121 * D);
expect(await burnPoints(ctx)).toBe(3250);
expect((await bal(id)).pointsActive).toBe(0);
expect(await reconcilePoints(db)).toHaveLength(0);
});
it('новоселье от агентства — один раз', async () => {
const { ctx } = makeCtx(NOW);
await db.agency.update({ where: { id: fx.agency.id }, data: { housewarmingPoints: 5000, housewarmingDays: 60 } });
const id = await client(ctx, 1204);
await applyAgencyCode(ctx, id, 'DOMIK');
await applyAgencyCode(ctx, id, 'DOMIK');
expect((await bal(id)).pointsActive).toBe(5000);
expect(await db.pointsAccrual.count({ where: { clientId: id } })).toBe(1);
});
});