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
35 changes: 35 additions & 0 deletions backend/src/domain/deals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { dealOverAt } from './deals.ts';

const at = (iso: string) => new Date(iso);
const ZERNO = { opensAt: '08:00', closesAt: '22:00', timezone: 'Europe/Moscow' };

describe('dealOverAt', () => {
it('keeps the end of a deal that ends before the closing', () => {
const deal = { startsAt: at('2026-09-27T15:00:00Z'), endsAt: at('2026-09-27T17:00:00Z') };
expect(dealOverAt(deal, ZERNO)).toEqual(deal.endsAt);
});

it('ends a deal at the closing when its end time is later', () => {
const deal = { startsAt: at('2026-09-27T18:48:00Z'), endsAt: at('2026-09-27T19:50:00Z') };
expect(dealOverAt(deal, ZERNO)).toEqual(at('2026-09-27T19:00:00Z'));
});

it('ends a deal that starts while the venue is closed right at its start', () => {
const afterClosing = { startsAt: at('2026-09-27T19:30:00Z'), endsAt: at('2026-09-27T20:30:00Z') };
expect(dealOverAt(afterClosing, ZERNO)).toEqual(afterClosing.startsAt);
const beforeOpening = { startsAt: at('2026-09-27T04:00:00Z'), endsAt: at('2026-09-27T19:00:00Z') };
expect(dealOverAt(beforeOpening, ZERNO)).toEqual(beforeOpening.startsAt);
});

it('ends at a closing after midnight', () => {
const deal = { startsAt: at('2026-09-27T20:30:00Z'), endsAt: at('2026-09-28T01:00:00Z') };
const bar = { ...ZERNO, opensAt: '18:00', closesAt: '02:00' };
expect(dealOverAt(deal, bar)).toEqual(at('2026-09-27T23:00:00Z'));
});

it('has no closing round the clock', () => {
const deal = { startsAt: at('2026-09-27T21:30:00Z'), endsAt: at('2026-09-28T20:00:00Z') };
expect(dealOverAt(deal, { ...ZERNO, opensAt: '00:00', closesAt: '00:00' })).toEqual(deal.endsAt);
});
});
13 changes: 13 additions & 0 deletions backend/src/domain/deals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { isOpenAt, nextClosingAt } from '../shared/time.ts';
import type { Deal, Venue } from './models.ts';

export function dealOverAt(
deal: Pick<Deal, 'startsAt' | 'endsAt'>,
venue: Pick<Venue, 'opensAt' | 'closesAt' | 'timezone'>,
): Date {
const { opensAt, closesAt, timezone } = venue;
const closing = nextClosingAt(opensAt, closesAt, deal.startsAt, timezone);
if (!closing) return deal.endsAt;
if (!isOpenAt(opensAt, closesAt, deal.startsAt, timezone)) return deal.startsAt;
return closing < deal.endsAt ? closing : deal.endsAt;
}
2 changes: 1 addition & 1 deletion backend/src/http/routes/bookings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const bookingsRoutes: FastifyPluginCallbackZod<{ bookings: BookingsServic
'deal_not_found (404): горящего предложения нет или оно на другую позицию.',
'offer_not_found (404): предложение показано другому гостю или на другую позицию, бронируйте без offerId.',
'venue_closed (409): заведение сейчас закрыто, покажите часы работы.',
'deal_not_active (409): горящее предложение снято, закончилось или ещё не началось, предложите бронь по цене меню без dealId.',
'deal_not_active (409): горящее предложение снято, закончилось (в том числе потому что заведение закрылось) или ещё не началось, предложите бронь по цене меню без dealId.',
'booking_exists (409): у гостя уже есть активная бронь этой позиции, откройте её из GET /api/v1/bookings.',
'deal_sold_out (409): порции по предложению закончились, предложите бронь по цене меню без dealId.',
`too_many_bookings (409): уже ${MAX_ACTIVE_BOOKINGS} активные брони, отмените одну из них или дождитесь, пока заведение её погасит.`,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/http/schemas/deals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const DealSchema = z
status: z
.enum(DEAL_STATUSES)
.describe(
'active - продаётся; scheduled - ещё не началась; sold_out - порции закончились; ended - время вышло; cancelled - снята заведением',
'active - продаётся; scheduled - ещё не началась; sold_out - порции закончились; ended - время вышло или заведение закрылось после начала предложения; cancelled - снята заведением',
),
})
.meta({
Expand Down
42 changes: 30 additions & 12 deletions backend/src/repositories/deals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,24 @@ const DEAL_COLUMNS = `d.id, d.venue_id, d.menu_item_id, d.price_rub, d.quantity_

const LIVE = 'd.cancelled_at is null and d.quantity_left > 0';

const LOCAL_START = '(d.starts_at at time zone v.timezone)';

const OPEN_AT_START = `(v.opens_at < v.closes_at and ${LOCAL_START}::time >= v.opens_at and ${LOCAL_START}::time < v.closes_at
or v.opens_at > v.closes_at and (${LOCAL_START}::time >= v.opens_at or ${LOCAL_START}::time < v.closes_at))`;

const CLOSING_AFTER_START = `((${LOCAL_START}::date + v.closes_at
+ case when ${LOCAL_START}::time < v.closes_at then interval '0 days' else interval '1 day' end) at time zone v.timezone)`;

const OVER_AT = `case
when v.opens_at = v.closes_at then d.ends_at
when ${OPEN_AT_START} then least(d.ends_at, ${CLOSING_AFTER_START})
else d.starts_at
end`;

const WITH_VENUE = 'join venues v on v.id = d.venue_id';

function visibleTo(nowParam: string): string {
return `${LIVE} and d.starts_at <= ${nowParam} and d.ends_at > ${nowParam}
return `${LIVE} and d.starts_at <= ${nowParam} and ${OVER_AT} > ${nowParam}
and m.archived_at is null and m.is_available`;
}

Expand Down Expand Up @@ -107,15 +123,17 @@ export async function cancelLiveInVenue(
): Promise<void> {
await db.query(
`update deals as d set cancelled_at = $3
where d.venue_id = $1 and d.id = $2 and ${LIVE} and d.ends_at > $3`,
from venues v
where v.id = d.venue_id and d.venue_id = $1 and d.id = $2 and ${LIVE} and ${OVER_AT} > $3`,
[venueId, id, now],
);
}

export async function cancelLiveForItem(db: Queryable, menuItemId: number, now: Date): Promise<void> {
await db.query(
`update deals as d set cancelled_at = $2
where d.menu_item_id = $1 and ${LIVE} and d.ends_at > $2`,
from venues v
where v.id = d.venue_id and d.menu_item_id = $1 and ${LIVE} and ${OVER_AT} > $2`,
[menuItemId, now],
);
}
Expand All @@ -141,8 +159,8 @@ export async function lockInVenue(db: Queryable, venueId: number, id: number): P
export async function findLiveForItem(db: Queryable, menuItemId: number, now: Date): Promise<Deal | null> {
const row = await maybeOne<DealRow>(
db,
`select ${DEAL_COLUMNS} from deals d
where d.menu_item_id = $1 and ${LIVE} and d.ends_at > $2
`select ${DEAL_COLUMNS} from deals d ${WITH_VENUE}
where d.menu_item_id = $1 and ${LIVE} and ${OVER_AT} > $2
order by d.id
limit 1`,
[menuItemId, now],
Expand All @@ -152,8 +170,8 @@ export async function findLiveForItem(db: Queryable, menuItemId: number, now: Da

export async function listLive(db: Queryable, venueId: number, now: Date): Promise<Deal[]> {
const { rows } = await db.query<DealRow>(
`select ${DEAL_COLUMNS} from deals d
where d.venue_id = $1 and ${LIVE} and d.ends_at > $2
`select ${DEAL_COLUMNS} from deals d ${WITH_VENUE}
where d.venue_id = $1 and ${LIVE} and ${OVER_AT} > $2
order by d.ends_at, d.id`,
[venueId, now],
);
Expand All @@ -162,9 +180,9 @@ export async function listLive(db: Queryable, venueId: number, now: Date): Promi

export async function listFinished(db: Queryable, venueId: number, now: Date, since: Date): Promise<Deal[]> {
const { rows } = await db.query<DealRow>(
`select ${DEAL_COLUMNS} from deals d
`select ${DEAL_COLUMNS} from deals d ${WITH_VENUE}
where d.venue_id = $1 and d.created_at >= $3
and (d.cancelled_at is not null or d.quantity_left = 0 or d.ends_at <= $2)
and (d.cancelled_at is not null or d.quantity_left = 0 or ${OVER_AT} <= $2)
order by d.ends_at desc, d.id desc`,
[venueId, now, since],
);
Expand All @@ -174,7 +192,7 @@ export async function listFinished(db: Queryable, venueId: number, now: Date, si
export async function listVisible(db: Queryable, venueIds: readonly number[], now: Date): Promise<Deal[]> {
if (venueIds.length === 0) return [];
const { rows } = await db.query<DealRow>(
`select ${DEAL_COLUMNS} from deals d
`select ${DEAL_COLUMNS} from deals d ${WITH_VENUE}
join menu_items m on m.id = d.menu_item_id
where d.venue_id = any($1::bigint[]) and ${visibleTo('$2')}
order by d.ends_at, d.id`,
Expand All @@ -186,7 +204,7 @@ export async function listVisible(db: Queryable, venueIds: readonly number[], no
export async function findVisible(db: Queryable, id: number, now: Date): Promise<Deal | null> {
const row = await maybeOne<DealRow>(
db,
`select ${DEAL_COLUMNS} from deals d
`select ${DEAL_COLUMNS} from deals d ${WITH_VENUE}
join menu_items m on m.id = d.menu_item_id
where d.id = $1 and ${visibleTo('$2')}`,
[id, now],
Expand All @@ -201,7 +219,7 @@ export async function countVisibleByVenue(
): Promise<Map<number, number>> {
if (venueIds.length === 0) return new Map();
const { rows } = await db.query<{ venue_id: number; deals: number }>(
`select d.venue_id, count(*) as deals from deals d
`select d.venue_id, count(*) as deals from deals d ${WITH_VENUE}
join menu_items m on m.id = d.menu_item_id
where d.venue_id = any($1::bigint[]) and ${visibleTo('$2')}
group by d.venue_id`,
Expand Down
16 changes: 15 additions & 1 deletion backend/src/services/bookings.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,24 @@ describe('creating bookings', () => {
code: 'menu_item_not_found',
});
clock.set('2026-09-25T19:00:00Z');
await expect(service.create(GUEST, { menuItemId: eclair.id, dealId: deal.id })).rejects.toMatchObject({
await expect(service.create(GUEST, { menuItemId: eclair.id })).rejects.toMatchObject({
status: 409,
code: 'venue_closed',
});
});

it('says that a deal is over once the venue closes, even before its end time', async () => {
clock.set('2026-09-25T18:48:00Z');
const late = await seedDeal(pool, tart, { startsAt: clock.now(), endsAt: later(HOUR) });
clock.set('2026-09-25T19:05:00Z');
for (const dealId of [late.id, deal.id]) {
const menuItemId = dealId === late.id ? tart.id : eclair.id;
await expect(service.create(GUEST, { menuItemId, dealId })).rejects.toMatchObject({
status: 409,
code: 'deal_not_active',
});
}
expect(await quantityLeft(late.id)).toBe(5);
expect(await quantityLeft(deal.id)).toBe(2);
});

Expand Down
18 changes: 13 additions & 5 deletions backend/src/services/bookings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
normalizeBookingCode,
qrPayload,
} from '../domain/bookings.ts';
import { dealOverAt } from '../domain/deals.ts';
import type { Booking, Deal, MenuItem, Venue } from '../domain/models.ts';
import type { BookingNotice, Notifier } from '../ports/notifier.ts';
import * as bookings from '../repositories/bookings.ts';
Expand Down Expand Up @@ -81,8 +82,8 @@ const bookingNotFound = () => notFound('booking_not_found', 'Booking not found')
const bookingExpired = () => conflict('booking_expired', 'The booking has expired, the portion is released');
const bookingNotActive = () => conflict('booking_not_active', 'The booking is already redeemed or cancelled');

function dealRunning(deal: Deal, now: Date): boolean {
return deal.cancelledAt === null && deal.startsAt <= now && deal.endsAt > now;
function dealRunning(deal: Deal, venue: Venue, now: Date): boolean {
return deal.cancelledAt === null && deal.startsAt <= now && dealOverAt(deal, venue) > now;
}

async function lockBookableItem(
Expand All @@ -100,10 +101,16 @@ async function lockBookableItem(
return { item, venue };
}

async function lockBookableDeal(db: Queryable, item: MenuItem, dealId: number, now: Date): Promise<Deal> {
async function lockBookableDeal(
db: Queryable,
item: MenuItem,
venue: Venue,
dealId: number,
now: Date,
): Promise<Deal> {
const deal = await deals.lockInVenue(db, item.venueId, dealId);
if (deal?.menuItemId !== item.id) throw notFound('deal_not_found', 'Deal not found');
if (!dealRunning(deal, now)) {
if (!dealRunning(deal, venue, now)) {
throw conflict('deal_not_active', 'The deal is cancelled, over or has not started yet');
}
return deal;
Expand Down Expand Up @@ -161,10 +168,11 @@ export function createBookingsService({
): Promise<BookingView> {
if (!(await users.lock(client, userId))) throw notFound('user_not_found', 'User not found');
const { item, venue } = await lockBookableItem(client, input.menuItemId, userId);
const deal =
input.dealId === undefined ? null : await lockBookableDeal(client, item, venue, input.dealId, now);
if (!isOpenAt(venue.opensAt, venue.closesAt, now, venue.timezone)) {
throw conflict('venue_closed', 'The venue is closed now');
}
const deal = input.dealId === undefined ? null : await lockBookableDeal(client, item, input.dealId, now);
const activeItems = await bookings.listActiveItemIds(client, userId);
if (activeItems.includes(item.id)) {
throw conflict('booking_exists', 'You already have an active booking of this item');
Expand Down
60 changes: 60 additions & 0 deletions backend/src/services/catalog.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,66 @@ describe('deal card', () => {
});
});

describe('deals after the closing', () => {
const at = (iso: string) => new Date(iso);

it('treats a deal as over once the venue closes, even before its end time', async () => {
const venue = await seedVenue(pool, 1, { name: 'Зерно', location: BAUMANA });
const eclair = await seedMenuItem(pool, venue.id, { name: 'Эклер' });
const deal = await seedDeal(pool, eclair, {
startsAt: at('2026-09-25T18:48:00Z'),
endsAt: at('2026-09-25T19:50:00Z'),
});
clock.set('2026-09-25T18:55:00Z');
expect((await catalog.deal(GUEST, deal.id)).deal.status).toBe('active');

clock.set('2026-09-25T19:05:00Z');
await expect(catalog.deal(GUEST, deal.id)).rejects.toMatchObject({ status: 404, code: 'deal_not_found' });
expect((await catalog.venue(GUEST, venue.id)).deals).toEqual([]);
expect((await catalog.venues(GUEST, near(BAUMANA))).map((card) => card.activeDeals)).toEqual([0]);
expect(await deals.listVisible(pool, [venue.id], clock.now())).toEqual([]);
});

it('hides a deal published while the venue was closed', async () => {
const venue = await seedVenue(pool, 1, { name: 'Зерно', location: BAUMANA });
const eclair = await seedMenuItem(pool, venue.id, { name: 'Эклер' });
const deal = await seedDeal(pool, eclair, {
startsAt: at('2026-09-25T19:30:00Z'),
endsAt: at('2026-09-25T20:30:00Z'),
});
clock.set('2026-09-25T19:45:00Z');
await expect(catalog.deal(GUEST, deal.id)).rejects.toMatchObject({ code: 'deal_not_found' });
expect((await catalog.venues(GUEST, near(BAUMANA))).map((card) => card.activeDeals)).toEqual([0]);
});

it('keeps deals past midnight and round the clock until their end', async () => {
const bar = await seedVenue(pool, 1, {
name: 'Бар',
location: BAUMANA,
opensAt: '18:00',
closesAt: '02:00',
});
const allDay = await seedVenue(pool, 2, {
name: 'Пенка',
location: BAUMANA,
opensAt: '00:00',
closesAt: '00:00',
});
const late = await seedDeal(pool, await seedMenuItem(pool, bar.id), {
startsAt: at('2026-09-25T20:00:00Z'),
endsAt: at('2026-09-25T22:30:00Z'),
});
const night = await seedDeal(pool, await seedMenuItem(pool, allDay.id), {
startsAt: at('2026-09-25T20:00:00Z'),
endsAt: at('2026-09-26T02:00:00Z'),
});
clock.set('2026-09-25T22:15:00Z');
expect((await catalog.deal(GUEST, late.id)).deal.status).toBe('active');
clock.set('2026-09-26T01:00:00Z');
expect((await catalog.deal(GUEST, night.id)).deal.status).toBe('active');
});
});

describe('demo mode', () => {
it('measures from the centre of Kazan when the point is more than 50 km away', async () => {
await seedDemoVenue(pool, 900001, { name: 'Зерно', location: BAUMANA });
Expand Down
22 changes: 22 additions & 0 deletions backend/src/services/deals.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,28 @@ describe('deals and opening hours', () => {
});
});

describe('deals after the closing', () => {
it('counts a deal as ended once the venue closes, even before its end time', async () => {
clock.set('2026-09-25T18:48:00Z');
const late = await seedDeal(pool, item, {
startsAt: clock.now(),
endsAt: new Date('2026-09-25T19:50:00Z'),
});
expect((await service.list(OWNER, 'active')).map((view) => view.deal.id)).toEqual([late.id]);

clock.set('2026-09-25T19:05:00Z');
expect(await service.list(OWNER, 'active')).toEqual([]);
expect(await service.list(OWNER, 'finished')).toMatchObject([{ status: 'ended', deal: { id: late.id } }]);
await expect(service.update(OWNER, late.id, { quantityLeft: 1 })).rejects.toMatchObject({
status: 409,
code: 'deal_finished',
});

clock.set('2026-09-26T06:00:00Z');
expect((await service.create(OWNER, input({ endsAt: inHours(1) }))).status).toBe('active');
});
});

describe('listing deals', () => {
it('lists live deals by end time and finished deals of the last 7 days newest first', async () => {
const other = await seedMenuItem(pool, venue.id, { name: 'Круассан', priceRub: 150 });
Expand Down
25 changes: 17 additions & 8 deletions backend/src/services/deals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,36 @@ const deal: Deal = {
};

const at = (iso: string) => new Date(iso);
const ZERNO = { opensAt: '08:00', closesAt: '22:00', timezone: 'Europe/Moscow' };

describe('dealStatus', () => {
it('is scheduled until the start and active from the start', () => {
expect(dealStatus(deal, at('2026-09-25T09:59:59.999Z'))).toBe('scheduled');
expect(dealStatus(deal, at('2026-09-25T10:00:00Z'))).toBe('active');
expect(dealStatus(deal, at('2026-09-25T11:59:59.999Z'))).toBe('active');
expect(dealStatus(deal, ZERNO, at('2026-09-25T09:59:59.999Z'))).toBe('scheduled');
expect(dealStatus(deal, ZERNO, at('2026-09-25T10:00:00Z'))).toBe('active');
expect(dealStatus(deal, ZERNO, at('2026-09-25T11:59:59.999Z'))).toBe('active');
});

it('ends exactly at the end time', () => {
expect(dealStatus(deal, at('2026-09-25T12:00:00Z'))).toBe('ended');
expect(dealStatus(deal, ZERNO, at('2026-09-25T12:00:00Z'))).toBe('ended');
});

it('ends at the closing of the venue when the end time is later', () => {
const late = { ...deal, startsAt: at('2026-09-25T18:48:00Z'), endsAt: at('2026-09-25T19:50:00Z') };
expect(dealStatus(late, ZERNO, at('2026-09-25T18:59:59.999Z'))).toBe('active');
expect(dealStatus(late, ZERNO, at('2026-09-25T19:00:00Z'))).toBe('ended');
const allDay = { ...ZERNO, opensAt: '00:00', closesAt: '00:00' };
expect(dealStatus(late, allDay, at('2026-09-25T19:30:00Z'))).toBe('active');
});

it('is sold out when no portions are left, even after the end', () => {
const soldOut = { ...deal, quantityLeft: 0 };
expect(dealStatus(soldOut, at('2026-09-25T11:00:00Z'))).toBe('sold_out');
expect(dealStatus(soldOut, at('2026-09-25T13:00:00Z'))).toBe('sold_out');
expect(dealStatus(soldOut, ZERNO, at('2026-09-25T11:00:00Z'))).toBe('sold_out');
expect(dealStatus(soldOut, ZERNO, at('2026-09-25T13:00:00Z'))).toBe('sold_out');
});

it('reports cancellation before anything else', () => {
const cancelled = { ...deal, quantityLeft: 0, cancelledAt: at('2026-09-25T10:30:00Z') };
expect(dealStatus(cancelled, at('2026-09-25T09:00:00Z'))).toBe('cancelled');
expect(dealStatus(cancelled, at('2026-09-25T13:00:00Z'))).toBe('cancelled');
expect(dealStatus(cancelled, ZERNO, at('2026-09-25T09:00:00Z'))).toBe('cancelled');
expect(dealStatus(cancelled, ZERNO, at('2026-09-25T13:00:00Z'))).toBe('cancelled');
});
});
Loading