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>; beforeEach(async () => { fx = await fixtures(); }); afterAll(() => db.$disconnect()); type C = ReturnType['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 = {}) { 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); }); });