Files
BarevClub/packages/services/test/points.test.ts
T
2026-10-25 22:57:03 +04:00

213 lines
13 KiB
TypeScript

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);
});
});