Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 103 additions & 16 deletions backend/src/bot/venue/deals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,12 +396,9 @@ describe('hot deal wizard', () => {
expect(chat.states.peek(OWNER_ID).flow).toMatchObject({ step: 'confirm', draft: { endsAt } });
});

it.each([
['is open round the clock', '2026-09-26T09:00:00Z', { opensAt: '00:00', closesAt: '00:00' }],
['closes in ten minutes', '2026-09-26T18:50:00Z', {}],
])('does not offer closing time when the venue %s', async (_case, now, hours) => {
const chat = venueChat({ now });
chat.fake.seedVenue(hours);
it('does not offer closing time when the venue is open round the clock', async () => {
const chat = venueChat({ now: '2026-09-26T21:30:00Z' });
chat.fake.seedVenue({ opensAt: '00:00', closesAt: '00:00' });
putWizard(chat, 'until', chosen(chat.fake.seedItem(), { quantity: 5, priceRub: 120 }));

const [reply] = sent(await chat.send('до закрытия'));
Expand All @@ -410,24 +407,99 @@ describe('hot deal wizard', () => {
expect(answers(await chat.press('vn:dl:until:close', reply?.messageId))[0]?.notification).toBe(
'Кнопка устарела',
);
await chat.press('vn:dl:until:120', reply?.messageId);
expect(chat.states.peek(OWNER_ID).flow).toMatchObject({
step: 'confirm',
draft: { endsAt: '2026-09-26T23:30:00.000Z' },
});
});

it('warns that guests cannot book while the venue is closed', async () => {
const chat = venueChat({ now: '2026-09-26T04:00:00Z' });
it.each([
['an hour and a half', '2026-09-26T17:30:00Z', ['vn:dl:until:60', 'vn:dl:until:close']],
['40 minutes', '2026-09-26T18:20:00Z', ['vn:dl:until:close']],
])('offers only the durations that end before the closing in %s', async (_case, now, offered) => {
const chat = venueChat({ now });
chat.fake.seedVenue();
putWizard(chat, 'discount', chosen(chat.fake.seedItem(), { quantity: 3 }));

const [reply] = answers(await chat.press('vn:dl:disc:50', WIZARD));

expect(reply?.message?.text).toBe(
[
'Заведение сейчас закрыто по часам работы: гости не смогут забронировать до открытия.',
'',
'Эклер: 100 ₽ вместо 200 ₽ (-50%), 3 шт.',
'До скольки продаём?',
].join('\n'),
);
expect(reply?.message?.text).toBe('Эклер: 100 ₽ вместо 200 ₽ (-50%), 3 шт.\nДо скольки продаём?');
expect(payloads(reply?.message)).toEqual([...offered, 'vn:dl:cancel']);
expect(labels(reply?.message)).toContain('До закрытия, 22:00');
expect(answers(await chat.press('vn:dl:until:120', WIZARD))[0]?.notification).toBe('Кнопка устарела');
expect(chat.states.peek(OWNER_ID).flow).toMatchObject({ step: 'until' });
});

it('does not offer any end time ten minutes before the closing', async () => {
const chat = venueChat({ now: '2026-09-26T18:50:00Z' });
chat.fake.seedVenue();
putWizard(chat, 'until', chosen(chat.fake.seedItem(), { quantity: 5, priceRub: 120 }));

const [reply] = sent(await chat.send('до закрытия'));

expect(reply).toMatchObject({
text: 'До закрытия меньше 15 минут, горящее можно выставить завтра с 08:00.',
buttons: [[DEALS]],
});
expect(chat.states.peek(OWNER_ID).flow).toBeNull();
for (const payload of ['vn:dl:until:60', 'vn:dl:until:120', 'vn:dl:until:close']) {
expect(answers(await chat.press(payload, WIZARD))[0]?.message?.text).toBe(
'Мастер устарел, начните заново',
);
}
expect(chat.states.peek(OWNER_ID).flow).toBeNull();
expect(chat.fake.services.deals.create).not.toHaveBeenCalled();
});

it.each([
[
'ten minutes before the closing',
'2026-09-26T18:50:00Z',
{},
'До закрытия меньше 15 минут, горящее можно выставить завтра с 08:00.',
],
[
'after the closing',
'2026-09-26T19:30:00Z',
{},
'Заведение сейчас закрыто, горящее можно выставить завтра с 08:00.',
],
[
'before the opening',
'2026-09-26T04:00:00Z',
{},
'Заведение сейчас закрыто, горящее можно выставить сегодня с 08:00.',
],
[
'ten minutes before a closing after midnight',
'2026-09-26T22:50:00Z',
{ opensAt: '18:00', closesAt: '02:00' },
'До закрытия меньше 15 минут, горящее можно выставить сегодня с 18:00.',
],
])('explains that a deal cannot start %s', async (_case, now, hours, text) => {
const chat = venueChat({ now });
chat.fake.seedVenue(hours);
chat.fake.seedItem();

const [reply] = answers(await chat.press('vn:dl:new', 'mid.deals'));

expect(reply?.message).toMatchObject({ text, buttons: [[DEALS]] });
expect(chat.states.peek(OWNER_ID).flow).toBeNull();
});

it('stops the wizard when the venue closes meanwhile', async () => {
const chat = venueChat({ now: '2026-09-26T19:30:00Z' });
chat.fake.seedVenue();
putWizard(chat, 'discount', chosen(chat.fake.seedItem(), { quantity: 3 }));

const [reply] = answers(await chat.press('vn:dl:disc:50', WIZARD));

expect(reply?.message).toMatchObject({
text: 'Заведение сейчас закрыто, горящее можно выставить завтра с 08:00.',
buttons: [[DEALS]],
});
expect(chat.states.peek(OWNER_ID).flow).toBeNull();
});

it('explains an existing deal on the item', async () => {
Expand Down Expand Up @@ -481,6 +553,21 @@ describe('hot deal wizard', () => {
});
});

it('asks for the end time again when the deal would end after the closing', async () => {
const chat = ownerChat();
atConfirm(chat, chat.fake.seedItem());
vi.mocked(chat.fake.services.deals.create).mockRejectedValueOnce(
unprocessable('deal_ends_after_closing', 'After closing'),
);

const [reply] = sent(await chat.press('vn:dl:ok', WIZARD));

expect(reply?.text).toBe(
'Время окончания позже закрытия заведения, выберите срок заново\n\nЭклер: 120 ₽ вместо 200 ₽ (-40%), 5 шт.\nДо скольки продаём?',
);
expect(chat.states.peek(OWNER_ID).flow).toMatchObject({ step: 'until' });
});

it.each([
[
'a hidden item',
Expand Down
74 changes: 55 additions & 19 deletions backend/src/bot/venue/deals.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { MenuItem, Venue } from '../../domain/models.ts';
import type { Button, OutgoingMessage } from '../../ports/messenger.ts';
import type { DealView } from '../../services/deals.ts';
import { formatLocalTime, isOpenAt, nextClosingAt } from '../../shared/time.ts';
import { formatLocalTime, isOpenAt, localParts, minutesOfDay, nextClosingAt } from '../../shared/time.ts';
import { answerStale, byAction, parseId } from '../callbacks.ts';
import type { BotContext, BotKit, MessageInput } from '../context.ts';
import type { ActiveFlow, Flow } from '../state.ts';
Expand All @@ -28,6 +28,7 @@ import {
dealOfferText,
dealPublished,
dealQuestion,
dealsPausedText,
dealsText,
dealSummary,
discountOption,
Expand All @@ -47,7 +48,6 @@ import {
UNTIL_QUESTION,
untilClosingOption,
VENUE_BUTTONS,
VENUE_CLOSED_WARNING,
VENUE_ERROR_TEXTS,
WIZARD_STALE,
withNote,
Expand All @@ -70,6 +70,11 @@ type ChosenOffer = ChosenItem & DealOffer;
interface Rendered {
state: DealState | null;
screen: OutgoingMessage;
paused?: boolean;
}

interface SaleWindow {
closing: Date | null;
}

interface Presentation {
Expand All @@ -93,6 +98,7 @@ const PUBLISH_FAILURES = [
'deal_exists',
'deal_price_not_lower',
'deal_window_invalid',
'deal_ends_after_closing',
'menu_item_unavailable',
'menu_item_not_found',
] as const satisfies readonly VenueErrorCode[];
Expand Down Expand Up @@ -122,9 +128,26 @@ function discountOptions(regularRub: number): { percent: number; priceRub: numbe
})).filter(({ priceRub }) => priceRub >= 1 && priceRub < regularRub);
}

function closingTime(venue: Venue, now: Date): Date | null {
function saleWindow(venue: Venue, now: Date): SaleWindow | null {
if (!isOpenAt(venue.opensAt, venue.closesAt, now, venue.timezone)) return null;
const closing = nextClosingAt(venue.opensAt, venue.closesAt, now, venue.timezone);
return closing && closing.getTime() - now.getTime() >= CLOSING_MARGIN_MS ? closing : null;
if (closing && closing.getTime() - now.getTime() < CLOSING_MARGIN_MS) return null;
return { closing };
}

function endAfter(now: Date, minutes: number): Date {
return new Date(now.getTime() + minutes * MINUTE_MS);
}

function fittingDurations({ closing }: SaleWindow, now: Date) {
return DURATION_CHOICES.filter(({ minutes }) => !closing || endAfter(now, minutes) <= closing);
}

function pausedScreen(venue: Venue, now: Date): OutgoingMessage {
const { hour, minute } = localParts(now, venue.timezone);
const opensToday = hour * 60 + minute < (minutesOfDay(venue.opensAt) ?? 0);
const open = isOpenAt(venue.opensAt, venue.closesAt, now, venue.timezone);
return { text: dealsPausedText(open, opensToday, venue.opensAt), buttons: [dealsRow()] };
}

function cancelRow(): Button[] {
Expand Down Expand Up @@ -178,19 +201,24 @@ function discountScreen(item: ChosenItem, quantity: number): OutgoingMessage {
}

function endsAtChoice(value: string | undefined, venue: Venue, now: Date): Date | null {
if (value === UNTIL_CLOSING) return closingTime(venue, now);
const duration = DURATION_CHOICES.find(({ id }) => id === value);
return duration ? new Date(now.getTime() + duration.minutes * MINUTE_MS) : null;
const window = saleWindow(venue, now);
if (!window) return null;
if (value === UNTIL_CLOSING) return window.closing;
const duration = fittingDurations(window, now).find(({ id }) => id === value);
return duration ? endAfter(now, duration.minutes) : null;
}

function untilScreen(offer: ChosenOffer, venue: Venue, now: Date): OutgoingMessage {
const closing = closingTime(venue, now);
const closed = !isOpenAt(venue.opensAt, venue.closesAt, now, venue.timezone);
const closingLabel = closing ? untilClosingOption(formatLocalTime(closing, venue.timezone)) : null;
function untilScreen(offer: ChosenOffer, window: SaleWindow, venue: Venue, now: Date): OutgoingMessage {
const durations = fittingDurations(window, now);
const closingLabel = window.closing
? untilClosingOption(formatLocalTime(window.closing, venue.timezone))
: null;
return {
text: [...(closed ? [VENUE_CLOSED_WARNING, ''] : []), dealOfferText(offer), UNTIL_QUESTION].join('\n'),
text: [dealOfferText(offer), UNTIL_QUESTION].join('\n'),
buttons: [
DURATION_CHOICES.map(({ id, label }) => venueButton(label, 'dl', 'until', id)),
...(durations.length > 0
? [durations.map(({ id, label }) => venueButton(label, 'dl', 'until', id))]
: []),
...(closingLabel ? [[venueButton(closingLabel, 'dl', 'until', UNTIL_CLOSING)]] : []),
cancelRow(),
],
Expand All @@ -208,7 +236,12 @@ function inputScreen(text: string): OutgoingMessage {
return { text, buttons: [cancelRow()] };
}

function stepScreen({ step, draft }: DealState, venue: Venue, now: Date): OutgoingMessage | null {
function stepScreen(
{ step, draft }: DealState,
window: SaleWindow,
venue: Venue,
now: Date,
): OutgoingMessage | null {
const item = chosenItem(draft);
const offer = chosenOffer(draft);
switch (step) {
Expand All @@ -223,7 +256,7 @@ function stepScreen({ step, draft }: DealState, venue: Venue, now: Date): Outgoi
case 'price_input':
return item ? inputScreen(pricePrompt(item.itemPriceRub - 1)) : null;
case 'until':
return offer ? untilScreen(offer, venue, now) : null;
return offer ? untilScreen(offer, window, venue, now) : null;
case 'confirm':
return offer && draft.endsAt !== undefined ? confirmScreen(offer, new Date(draft.endsAt), venue) : null;
}
Expand Down Expand Up @@ -280,14 +313,17 @@ export function createDealScreens({ services }: BotKit) {
}

async function render(ctx: BotContext, venue: Venue, state: DealState): Promise<Rendered> {
const screen = stepScreen(state, venue, ctx.now);
const window = saleWindow(venue, ctx.now);
if (!window) return { state: null, screen: pausedScreen(venue, ctx.now), paused: true };
const screen = stepScreen(state, window, venue, ctx.now);
if (screen) return { state, screen };
return renderItems(ctx, state.step === 'item' ? state.page : 1);
}

async function present(ctx: BotContext, venue: Venue, next: DealState, how: Presentation): Promise<void> {
const { state, screen } = await render(ctx, venue, next);
const message = how.error === undefined ? screen : { ...screen, text: withNote(how.error, screen.text) };
const { state, screen, paused } = await render(ctx, venue, next);
const message =
how.error === undefined || paused ? screen : { ...screen, text: withNote(how.error, screen.text) };
if (how.via === 'answer') {
if (state) await saveFlow(ctx, { name: 'deal_wizard', ...state, messageId: ctx.callbackMessageId });
else await clearFlow(ctx, 'deal_wizard');
Expand Down Expand Up @@ -403,7 +439,7 @@ export function createDealScreens({ services }: BotKit) {
code: (typeof PUBLISH_FAILURES)[number],
): Promise<void> {
const error = VENUE_ERROR_TEXTS[code];
if (code === 'deal_window_invalid') {
if (code === 'deal_window_invalid' || code === 'deal_ends_after_closing') {
const draft = { ...state.draft, endsAt: undefined };
await present(ctx, venue, { ...state, step: 'until', draft }, { via: 'reply', error });
return;
Expand Down
8 changes: 6 additions & 2 deletions backend/src/bot/venue/texts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const VENUE_ERROR_TEXTS = {
deal_exists: 'На эту позицию уже есть горящее предложение',
deal_price_not_lower: 'Цена со скидкой должна быть ниже обычной',
deal_window_invalid: 'Время окончания должно быть в ближайшие 24 часа',
deal_ends_after_closing: 'Время окончания позже закрытия заведения, выберите срок заново',
menu_item_unavailable: 'Позиция скрыта от гостей, включите её в кабинете',
menu_item_not_found: 'Позиция уже удалена, обновите список',
deal_not_found: 'Позиция уже удалена, обновите список',
Expand Down Expand Up @@ -155,8 +156,6 @@ export const QUANTITY_PROMPT = `Напишите, сколько порций п
export const QUANTITY_INVALID = `Нужно целое число от 1 до ${DEAL_QUANTITY_LIMIT}, например 7.`;
export const DISCOUNT_QUESTION = 'Какая скидка?';
export const UNTIL_QUESTION = 'До скольки продаём?';
export const VENUE_CLOSED_WARNING =
'Заведение сейчас закрыто по часам работы: гости не смогут забронировать до открытия.';
export const DEAL_CANCELLED = 'Хорошо, ничего не публикую.';

const NO_BOOKINGS = 'Активных броней нет.';
Expand Down Expand Up @@ -354,6 +353,11 @@ export function priceInvalid(maxRub: number): string {
return `Нужно целое число от 1 до ${maxRub}: цена со скидкой ниже обычной.`;
}

export function dealsPausedText(open: boolean, opensToday: boolean, opensAt: string): string {
const reason = open ? 'До закрытия меньше 15 минут' : 'Заведение сейчас закрыто';
return `${reason}, горящее можно выставить ${opensToday ? 'сегодня' : 'завтра'} с ${opensAt}.`;
}

export function untilClosingOption(closesAt: string): string {
return `${VENUE_BUTTONS.untilClosing}, ${closesAt}`;
}
Expand Down
14 changes: 13 additions & 1 deletion backend/src/http/routes/venue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,7 @@ describe('deal routes', () => {
unprocessable('menu_item_unavailable', 'x'),
unprocessable('deal_price_not_lower', 'x'),
unprocessable('deal_window_invalid', 'x'),
unprocessable('deal_ends_after_closing', 'x'),
];
const create = vi.fn<DealsService['create']>();
for (const failure of failures) create.mockRejectedValueOnce(failure);
Expand All @@ -717,7 +718,8 @@ describe('deal routes', () => {
.fn<DealsService['update']>()
.mockResolvedValueOnce({ ...dealView, deal: { ...sampleDeal, quantityLeft: 0 }, status: 'sold_out' })
.mockRejectedValueOnce(conflict('deal_finished', 'over'))
.mockRejectedValueOnce(unprocessable('deal_quantity_invalid', 'too many'));
.mockRejectedValueOnce(unprocessable('deal_quantity_invalid', 'too many'))
.mockRejectedValueOnce(unprocessable('deal_ends_after_closing', 'after closing'));
app = await buildTestApp({ services: { deals: dealsStub({ update }) } });
const url = '/api/v1/venue/deals/21';
const path = '/api/v1/venue/deals/{id}';
Expand Down Expand Up @@ -745,6 +747,16 @@ describe('deal routes', () => {
expect(tooMany.statusCode).toBe(422);
expectContract(tooMany, 'PATCH', path);

const afterClosing = await app.inject({
method: 'PATCH',
url,
headers: owner,
payload: { endsAt: '2026-09-25T19:30:00Z' },
});
expect(afterClosing.statusCode).toBe(422);
expectContract(afterClosing, 'PATCH', path);
expect(afterClosing.json()).toMatchObject({ code: 'deal_ends_after_closing' });

for (const payload of [{}, { quantityLeft: -1 }, { endsAt: 'soon' }]) {
const invalid = await app.inject({ method: 'PATCH', url, headers: owner, payload });
expect(invalid.statusCode, JSON.stringify(payload)).toBe(400);
Expand Down
Loading