Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

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:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+326 -1
View File
@@ -16,11 +16,46 @@ import {
providerAction,
quoteForClient,
respondToOffer,
orderPayout,
rescheduleByClient,
setChecklistItem,
startClient,
transitionOrder,
DomainError,
createWebRequest,
linkWebRequest,
normalizePhone,
updateWebRequest,
createLead,
normalizeContact,
updateLead,
updateLeadByAdmin,
addClientMessage,
clientRelocation,
conversation,
createAppLead,
createClientRequest,
getMembership,
grantSubscription,
listConversations,
replyToClient,
toggleRelocationDoc,
updateLeadStep,
addLeadDoc,
addLeadStep,
addProviderMessage,
replyTo,
saveRelocationItem,
acceptPartnerInvite,
cardQrToken,
checkCard,
clientPartners,
createOwnerInvite,
createStaffInvite,
partnerCabinet,
redeemOffer,
savePartnerOffer,
verifyCardToken,
} from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
@@ -106,7 +141,7 @@ describe('заказ (ORD-3…6)', () => {
const plus = await onboardedClient(ctx, 10, encodeStartPayload({ agency: 'DOMIK' }));
const plain = await onboardedClient(ctx, 11);
const p = { cleaningType: 'regular', rooms: 2 };
expect((await quoteForClient(ctx, plus, 'cleaning', p)).quote).toEqual({ basePrice: 19000, discountPct: 10, discountAmount: 1900, totalPrice: 17100, durationMinutes: 165 });
expect((await quoteForClient(ctx, plus, 'cleaning', p)).quote).toMatchObject({ basePrice: 19000, servicePrice: 19000, extras: [], discountPct: 10, discountAmount: 1900, totalPrice: 17100, durationMinutes: 165 });
expect((await quoteForClient(ctx, plain, 'cleaning', p)).quote.totalPrice).toBe(19000);
});
@@ -325,3 +360,293 @@ describe('оплата и напоминания', () => {
expect(events.filter((e) => e.type === 'order.reminder').map((e) => (e as { hours: number }).hours)).toEqual([24, 1]);
});
});
describe('выплаты клинеру по пресету', () => {
it('заказ хранит разбивку цены; заработок считается по строкам пресета', async () => {
const { ctx } = makeCtx(NOW);
const clientId = await onboardedClient(ctx, 70);
const order = await createOrder(ctx, {
clientId,
serviceCode: 'cleaning',
params: { cleaningType: 'regular', rooms: 2, bathrooms: 2, extras: { windows: 2 } },
scheduledAt: slotAt(2, 11),
addressText: 'Pushkin 10',
districtId: fx.kentron.id,
});
expect(order.priceBreakdown).toMatchObject({ variant: 'regular', base: 15000, rooms: { count: 1, amount: 4000 }, bathrooms: { count: 1, amount: 3000 } });
const preset = await db.payoutPreset.create({
data: {
name: 'Мидл',
role: 'CLEANER',
rules: { base: { regular: { type: 'PERCENT', value: 60 } }, room: { type: 'FIXED', value: 2500 }, bathroom: { type: 'FIXED', value: 1500 }, extras: { windows: { type: 'FIXED', value: 1000 } } },
},
});
await db.provider.update({ where: { id: fx.maria.id }, data: { payoutPresetId: preset.id } });
const maria = await db.provider.findUniqueOrThrow({ where: { id: fx.maria.id }, include: { company: true, payoutPreset: true } });
const full = await db.order.findUniqueOrThrow({ where: { id: order.id }, include: { service: true } });
// 60% от 15000 + 2500 за комнату + 1500 за санузел + 2 окна × 1000
expect(orderPayout(full, maria)).toBe(9000 + 2500 + 1500 + 2000);
// без пресета — индивидуальная ставка 70% от итога
expect(orderPayout(full, { ...maria, payoutPreset: null })).toBe(Math.floor(full.totalPrice * 0.7));
// сотрудник компании — ставка компании
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));
});
});
describe('заявка с сайта (/clean)', () => {
const input = (when = slotAt(2, 11)) => ({
serviceCode: 'cleaning',
params: { cleaningType: 'regular', rooms: 2, bathrooms: 1, extras: { windows: 2 } },
preferredAt: when,
districtCode: 'kentron',
addressText: 'Pushkin 10',
phone: '+374 (91) 12-34-56',
name: 'Anna',
locale: 'en' as const,
agency: 'DOMIK',
});
it('цена считается на сервере, телефон нормализуется, токен ведёт в бота с языком и агентством', async () => {
const { ctx } = makeCtx(NOW);
const r = await createWebRequest(ctx, input());
expect(r).toMatchObject({ status: 'NEW', phone: '+37491123456', totalPrice: 15000 + 4000 + 2 * 3500, locale: 'en' });
const token = await db.startToken.findUniqueOrThrow({ where: { id: r.token } });
expect(token).toMatchObject({ locale: 'en', agency: 'DOMIK' });
});
it('время вне окна, в прошлом или кривой телефон — отказ', async () => {
const { ctx } = makeCtx(NOW);
expect(await code(createWebRequest(ctx, input(slotAt(2, 21))))).toBe('SLOT_UNAVAILABLE');
expect(await code(createWebRequest(ctx, input(slotAt(0, 11))))).toBe('SLOT_UNAVAILABLE');
expect(await code(createWebRequest(ctx, { ...input(), phone: '12-34' }))).toBe('PHONE_INVALID');
expect(await code(createWebRequest(ctx, { ...input(), districtCode: 'mars' }))).toBe('ADDRESS_REQUIRED');
expect(normalizePhone('8 (999) 123-45-67')).toBe('+89991234567');
});
it('открыл бота: согласие с сайта, агентство, заказ на желаемое время; повторный /start ничего не дублирует', async () => {
const { ctx, events } = makeCtx(NOW);
const r = await createWebRequest(ctx, input());
const start = await startClient(ctx, tgUser(77), encodeStartPayload({ token: r.token }));
expect(start.locale).toBe('en');
const link = await linkWebRequest(ctx, r.token, start.clientId);
expect(link).toMatchObject({ kind: 'ordered', requestNumber: r.number, scheduledAt: slotAt(2, 11) });
const client = await db.client.findUniqueOrThrow({ where: { id: start.clientId }, include: { user: true } });
expect(client.consentAt).not.toBeNull();
expect(client.agencyId).toBe(fx.agency.id);
expect(client.user.phone).toBe('+37491123456');
const saved = await db.webRequest.findUniqueOrThrow({ where: { id: r.id }, include: { order: true } });
expect(saved.status).toBe('ORDERED');
// подарок агентства (Barev+) активировался вместе с согласием — в заказе уже скидка 10% на «Дом»
expect(link.kind === 'ordered' && link.gift).toBeTruthy();
expect(saved.order).toMatchObject({ status: 'NEW', basePrice: 26000, totalPrice: 23400, districtId: fx.kentron.id });
expect(events.filter((e) => e.type === 'order.created')).toHaveLength(1);
expect(await linkWebRequest(ctx, r.token, start.clientId)).toMatchObject({ kind: 'ordered', orderNumber: saved.order!.number });
expect(await db.order.count()).toBe(1);
// чужой Telegram по той же ссылке заявку не забирает
const other = await startClient(ctx, tgUser(78), encodeStartPayload({ token: r.token }));
expect(await linkWebRequest(ctx, r.token, other.clientId)).toEqual({ kind: 'none' });
});
it('нет свободного исполнителя — заявка остаётся оператору; отмену и возврат делает админ', async () => {
const { ctx } = makeCtx(NOW);
await db.provider.updateMany({ data: { active: false } });
const r = await createWebRequest(ctx, input());
const start = await startClient(ctx, tgUser(79));
expect(await linkWebRequest(ctx, r.token, start.clientId)).toMatchObject({ kind: 'linked', requestNumber: r.number });
expect(await db.order.count()).toBe(0);
expect((await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('LINKED');
await updateWebRequest(ctx, { id: r.id, status: 'CANCELLED', adminNote: 'перезвонить в пятницу' });
expect(await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).toMatchObject({ status: 'CANCELLED', adminNote: 'перезвонить в пятницу' });
await updateWebRequest(ctx, { id: r.id, status: 'REOPEN' });
expect((await db.webRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('LINKED');
});
});
describe('квиз на главной (заявка на переезд)', () => {
it('контакт: телефон или Telegram, мусор — отказ', async () => {
expect(normalizeContact('+374 91 12-34-56')).toEqual({ contact: '+37491123456', contactType: 'PHONE' });
expect(normalizeContact('@anna_k')).toEqual({ contact: '@anna_k', contactType: 'TELEGRAM' });
expect(normalizeContact('https://t.me/anna_k')).toEqual({ contact: '@anna_k', contactType: 'TELEGRAM' });
expect(normalizeContact('anna')).toBeNull();
const { ctx } = makeCtx(NOW);
expect(await code(createLead(ctx, { name: 'Anna', contact: '12', locale: 'ru' }))).toBe('CONTACT_INVALID');
expect(await code(createLead(ctx, { name: ' ', contact: '@anna_k', locale: 'ru' }))).toBe('NAME_REQUIRED');
});
it('шаг 1 сохраняется сразу, ответы дописываются по ключу, после отправки заявка не меняется', async () => {
const { ctx } = makeCtx(NOW);
const lead = await createLead(ctx, { name: 'Anna', contact: '@anna_k', locale: 'en', utm: { utm_source: 'ig' } });
expect(lead).toMatchObject({ status: 'NEW', submittedAt: null, locale: 'en', consentAt: NOW });
expect(await code(updateLead(ctx, { id: lead.id, key: 'wrong', moved: false }))).toBe('NOT_FOUND');
await updateLead(ctx, { id: lead.id, key: lead.key, moved: false, moveWhen: 'date', moveDate: slotAt(30, 0) });
await updateLead(ctx, { id: lead.id, key: lead.key, package: 'CUSTOM', options: { airport: 'premium', stay: 'hotel', stayBudgetFrom: 20000, stayBudgetTo: 40000, residence: true } });
const sent = await updateLead(ctx, { id: lead.id, key: lead.key, submit: true });
expect(sent).toMatchObject({ moved: false, moveWhen: 'date', moveDate: slotAt(30, 0), package: 'CUSTOM', submittedAt: NOW });
expect(sent.options).toMatchObject({ airport: 'premium', stayBudgetTo: 40000 });
await updateLead(ctx, { id: lead.id, key: lead.key, package: 'CONSULT' });
expect((await db.lead.findUniqueOrThrow({ where: { id: lead.id } })).package).toBe('CUSTOM');
expect(await updateLeadByAdmin(ctx, { id: lead.id, status: 'IN_WORK', adminNote: ' позвонить ' })).toMatchObject({ status: 'IN_WORK', adminNote: 'позвонить' });
});
it('«уже живу в Армении» — квиз закончен, заявка отправлена', async () => {
const { ctx } = makeCtx(NOW);
const lead = await createLead(ctx, { name: 'Ivan', contact: '+79991234567', locale: 'ru' });
expect(await updateLead(ctx, { id: lead.id, key: lead.key, moved: true })).toMatchObject({ moved: true, submittedAt: NOW });
});
});
describe('приложение: переезд, Elite, заявки оператору, консьерж', () => {
it('заявка из приложения строит план по шагам; оператор двигает шаг — клиенту событие', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await onboardedClient(ctx, 501);
const lead = await createAppLead(ctx, clientId, { moveWhen: 'month', package: 'CUSTOM', options: { airport: 'standard', stay: 'none', flat: true, residence: true } });
expect(lead.steps.map((s) => s.code)).toEqual(['airport', 'flat', 'residence']);
expect(lead).toMatchObject({ clientId, submittedAt: NOW, moved: false });
expect(await code(createAppLead(ctx, clientId, { moveWhen: 'month', package: 'CUSTOM', options: { airport: 'none' } }))).toBe('INVALID_PARAMS');
await updateLeadStep(ctx, { stepId: lead.steps[0]!.id, status: 'DONE', note: 'Водитель Арам, +374 91 000000' });
expect(events).toContainEqual({ type: 'lead.step', stepId: lead.steps[0]!.id });
expect((await db.lead.findUniqueOrThrow({ where: { id: lead.id } })).status).toBe('IN_WORK');
const plan = (await clientRelocation(ctx, clientId))!;
expect(plan.docs.map((d) => d.code)).toEqual(['passport', 'diploma', 'criminalRecord']);
await toggleRelocationDoc(ctx, clientId, plan.docs[1]!.id, true);
expect((await clientRelocation(ctx, clientId))!.docs.filter((d) => d.checked).map((d) => d.code)).toEqual(['diploma']);
const other = await onboardedClient(ctx, 599);
expect(await code(toggleRelocationDoc(ctx, other, plan.docs[0]!.id, true))).toBe('NOT_FOUND');
});
it('заявка с сайта находится по нику Telegram и получает план «под ключ»', async () => {
const { ctx } = makeCtx(NOW);
const site = await createLead(ctx, { name: 'Ann', contact: '@Ann_Moving', locale: 'en' });
await updateLead(ctx, { id: site.id, key: site.key, moved: false });
await updateLead(ctx, { id: site.id, key: site.key, package: 'TURNKEY', submit: true });
const start = await startClient(ctx, { ...tgUser(502), username: 'ann_moving' });
const plan = await clientRelocation(ctx, start.clientId);
expect(plan?.id).toBe(site.id);
expect(plan?.steps).toHaveLength(7);
});
it('Elite: своя скидка в заказе, заявка на подписку не дублируется, оператор включает', async () => {
const { ctx, events } = makeCtx(NOW);
await db.category.updateMany({ where: { code: 'HOME' }, data: { eliteDiscountPct: 35 } });
const clientId = await onboardedClient(ctx, 503);
const r1 = await createClientRequest(ctx, clientId, { kind: 'SUBSCRIPTION', plan: 'ELITE' });
const r2 = await createClientRequest(ctx, clientId, { kind: 'SUBSCRIPTION', plan: 'ELITE' });
expect(r2.id).toBe(r1.id);
await grantSubscription(ctx, { clientId, plan: 'ELITE', days: 30, requestId: r1.id });
expect((await db.clientRequest.findUniqueOrThrow({ where: { id: r1.id } })).status).toBe('DONE');
expect(events.some((e) => e.type === 'subscription.activated')).toBe(true);
expect((await getMembership(db, clientId, NOW)).level).toBe('ELITE');
const order = await newOrder(ctx, clientId);
// уборка 2 комнаты 15 000 + 4 000 → −35%
expect(order).toMatchObject({ basePrice: 19000, totalPrice: 12350 });
expect(await code(createClientRequest(ctx, clientId, { kind: 'SERVICE', serviceCode: 'nope' }))).toBe('NOT_FOUND');
});
it('консьерж: сообщения клиента непрочитаны, пока оператор не открыл диалог; ответ уходит в бота', async () => {
const { ctx, events } = makeCtx(NOW);
const clientId = await onboardedClient(ctx, 504);
await addClientMessage(ctx, clientId, 'Как продлить визу?');
expect((await listConversations(ctx))[0]).toMatchObject({ clientId, unread: 1 });
await conversation(ctx, clientId);
expect((await listConversations(ctx))[0]!.unread).toBe(0);
const admin = await db.adminUser.create({ data: { email: 'op@test', name: 'Оператор', passwordHash: 'x' } });
const msg = await replyToClient(ctx, { clientId, adminUserId: admin.id, text: 'Подскажем завтра утром' });
expect(events).toContainEqual({ type: 'concierge.reply', messageId: msg.id });
});
});
describe('шаблоны переезда и переписка с исполнителями', () => {
it('новый шаг в общем списке попадает в «под ключ» новым клиентам; у клиента — свои шаги и документы', async () => {
const { ctx } = makeCtx(NOW);
await saveRelocationItem(ctx, { kind: 'STEP', name: { ru: 'Школа для ребёнка' }, active: true, inTurnkey: true });
await saveRelocationItem(ctx, { kind: 'STEP', name: { ru: 'Скрытый' }, active: false, inTurnkey: true });
const clientId = await onboardedClient(ctx, 601);
const lead = await createAppLead(ctx, clientId, { moveWhen: 'later', package: 'TURNKEY' });
expect(lead.steps).toHaveLength(8);
expect(lead.steps.some((s) => s.code.startsWith('c-'))).toBe(true);
const custom = await addLeadStep(ctx, { leadId: lead.id, title: 'Купить сим-карту' });
expect(custom).toMatchObject({ title: 'Купить сим-карту', status: 'TODO' });
expect(await addLeadStep(ctx, { leadId: lead.id, code: 'airport' })).toMatchObject({ id: lead.steps.find((s) => s.code === 'airport')!.id });
await addLeadDoc(ctx, { leadId: lead.id, title: 'Справка 2-НДФЛ' });
expect((await clientRelocation(ctx, clientId))!.docs.map((d) => d.title ?? d.code)).toEqual(['passport', 'diploma', 'criminalRecord', 'Справка 2-НДФЛ']);
});
it('исполнитель пишет боту — операторам уведомление; ответ уходит исполнителю', async () => {
const { ctx, events } = makeCtx(NOW);
const provider = await db.provider.findFirstOrThrow({ where: { userId: { not: null } } });
const msg = await addProviderMessage(ctx, provider.id, 'Клиент не открывает дверь');
expect(events).toContainEqual({ type: 'concierge.message', messageId: msg!.id });
const admin = await db.adminUser.create({ data: { email: 'op2@test', name: 'Оператор', passwordHash: 'x' } });
const reply = await replyTo(ctx, { providerId: provider.id, adminUserId: admin.id, text: 'Звоним клиенту' });
expect(reply).toMatchObject({ providerId: provider.id, author: 'OPERATOR' });
expect((await db.conciergeMessage.findUniqueOrThrow({ where: { id: msg!.id } })).readAt).not.toBeNull();
const unlinked = await db.provider.create({ data: { name: 'Без бота', role: 'CLEANER', languages: ['ru'], inviteCode: 'X-NOBOT' } });
expect(await code(replyTo(ctx, { providerId: unlinked.id, adminUserId: admin.id, text: 'привет' }))).toBe('PROVIDER_NOT_LINKED');
});
});
describe('партнёры: QR клубной карты и кабинет в рабочем боте', () => {
async function setup(ctx: ReturnType<typeof makeCtx>['ctx']) {
const partner = await db.partner.create({ data: { category: 'cafe', name: 'Lumen', offer: { ru: 'кофе' } } });
const every = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: '−10% на всё' }, minLevel: null, limit: 'NONE', limitCount: 1, active: true });
const weekly = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: 'Кофе в подарок' }, minLevel: 'BAREV_PLUS', limit: 'WEEK', limitCount: 1, active: true });
const first = await savePartnerOffer(ctx, { partnerId: partner.id, title: { ru: 'Десерт на первый визит' }, minLevel: null, limit: 'ONCE', limitCount: 1, active: true });
const { url } = await createOwnerInvite(ctx, partner.id);
const owner = await acceptPartnerInvite(ctx, tgUser(800), url.split('start=')[1]!);
expect(owner.role).toBe('OWNER');
return { partner, every, weekly, first };
}
it('QR меняется каждую минуту, старый скриншот через 3 минуты не проходит, подделка не проходит', () => {
const { token } = cardQrToken('ABCD2345', NOW);
expect(token.length).toBeLessThanOrEqual(64);
expect(verifyCardToken(token, NOW)).toBe('ABCD2345');
expect(verifyCardToken(token, new Date(NOW.getTime() + 90_000))).toBe('ABCD2345');
expect(verifyCardToken(token, new Date(NOW.getTime() + 4 * 60_000))).toBeNull();
expect(verifyCardToken(token.replace('ABCD2345', 'ZZZZ2345'), NOW)).toBeNull();
});
it('сотрудник проверяет карту: уровень, что положено, лимиты «раз в неделю» и «первый визит»', async () => {
const { ctx, clock } = makeCtx(NOW);
const { partner, weekly, first } = await setup(ctx);
const { url } = await createStaffInvite(ctx, 800n);
expect((await acceptPartnerInvite(ctx, tgUser(801), url.split('start=')[1]!)).role).toBe('STAFF');
expect(await code(acceptPartnerInvite(ctx, tgUser(802), url.split('start=')[1]!))).toBe('INVITE_INVALID');
expect(await code(createStaffInvite(ctx, 801n))).toBe('FORBIDDEN');
const clientId = await onboardedClient(ctx, 810);
const card = (await db.client.findUniqueOrThrow({ where: { id: clientId } })).cardCode;
let check = await checkCard(ctx, { telegramId: 801n, token: `https://t.me/bot?start=${cardQrToken(card, NOW).token}` });
expect(check.level).toBe('BAREV');
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ eligible: false });
expect(await code(redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: weekly.id }))).toBe('OFFER_NOT_AVAILABLE');
expect(await redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: first.id })).toMatchObject({ remaining: 0 });
expect(await code(redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: first.id }))).toBe('OFFER_LIMIT_REACHED');
await grantSubscription(ctx, { clientId, plan: 'BAREV_PLUS', days: 30 });
check = await checkCard(ctx, { telegramId: 801n, token: cardQrToken(card, NOW).token });
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ eligible: true, remaining: 1 });
await redeemOffer(ctx, { telegramId: 801n, checkId: check.checkId, offerId: weekly.id });
// через неделю — снова можно (19.10 — понедельник, 26.10 — следующий)
clock.now = new Date(NOW.getTime() + 7 * 24 * 3600_000);
check = await checkCard(ctx, { telegramId: 801n, token: cardQrToken(card, clock.now).token });
expect(check.offers.find((o) => o.id === weekly.id)).toMatchObject({ remaining: 1 });
expect(check.offers.find((o) => o.id === first.id)).toMatchObject({ remaining: 0 });
// чужой человек проверить не может; собственник видит сотрудников и проверки
expect(await code(checkCard(ctx, { telegramId: 999n, token: cardQrToken(card, clock.now).token }))).toBe('FORBIDDEN');
const cab = await partnerCabinet(ctx, 800n);
expect(cab).toMatchObject({ role: 'OWNER', visits30: 3 });
expect(cab.members.map((m) => m.role)).toEqual(['OWNER', 'STAFF']);
expect((await partnerCabinet(ctx, 801n)).members).toEqual([]);
expect((await clientPartners(ctx, clientId)).find((p) => p.id === partner.id)!.offers).toHaveLength(3);
});
});
+9
View File
@@ -38,7 +38,16 @@ export const slotAt = (dayOffset: number, hour: number) => {
let tgSeq = 1000;
export async function fixtures() {
// шаблоны переезда (в базе их создаёт миграция; тесты чистят все таблицы)
const steps = ['consult', 'airport', 'stay', 'flat', 'registration', 'socialCard', 'bankCard', 'residence'];
const docs = ['passport', 'diploma', 'criminalRecord'];
await wipe();
await db.relocationItem.createMany({
data: [
...steps.map((code, i) => ({ kind: 'STEP' as const, code, name: { ru: code }, sortOrder: i, inTurnkey: code !== 'consult' })),
...docs.map((code, i) => ({ kind: 'DOC' as const, code, name: { ru: code }, sortOrder: i, inTurnkey: false })),
],
});
for (const [i, code] of YEREVAN_DISTRICTS.entries()) await db.district.create({ data: { code, sortOrder: i } });
const kentron = await db.district.findUniqueOrThrow({ where: { code: 'kentron' } });
const home = await db.category.create({ data: { code: 'HOME', barevPlusDiscountPct: DEFAULT_CATEGORY_DISCOUNTS.HOME } });