Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
213 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|