Вчера у четырёх подписок наступил срок продления. К утру оплатили одну. В отчёте уже стоит 25%, хотя у остальных ещё есть время. Для подписок с прошлой недели окно оплаты уже закрылось. Если поставить эти цифры рядом, на графике легко увидеть падение раньше, чем его можно измерить.

Я бы считал долю от числа подписок, у которых по графику наступил срок продления в этот день. Засчитываем оплату за следующий период, если она прошла до истечения 48 часов с планового срока. Предоплата тоже считается продлением. Платёж ровно на границе 48 часов считаем поздним. Границу стоит явно записать в правилах отчёта.

В исходных данных нужна одна строка на ожидаемое продление. В ней хранятся плановый срок и время первой успешной оплаты именно за этот период. Если после предоплаты поле expires_at в текущей подписке сдвигается вперёд, старый срок для отчёта придётся брать из истории. Иначе предоплата может исчезнуть из нужного дня целиком.

Вот пример с данными, полными за время до 11 ноября, 00:00 UTC. День определяется по плановому сроку продления. Строка за 9 ноября уже оплачена, но у остальных подписок этого дня 48 часов могли ещё не пройти. Поэтому в отчёт попадут 7 и 8 ноября целиком, а 9 ноября пока подождёт.

WITH renewals(id, due_at, paid_at) AS (
    VALUES
        (101, timestamptz '2026-11-07 09:00+00', timestamptz '2026-11-06 18:00+00'),
        (102, timestamptz '2026-11-07 10:00+00', timestamptz '2026-11-09 10:00+00'),
        (103, timestamptz '2026-11-07 11:00+00', timestamptz '2026-11-08 12:00+00'),
        (104, timestamptz '2026-11-08 11:00+00', NULL),
        (105, timestamptz '2026-11-08 18:00+00', timestamptz '2026-11-10 17:00+00'),
        (106, timestamptz '2026-11-09 09:00+00', timestamptz '2026-11-09 09:30+00')
),
days(cohort_date) AS (
    VALUES (date '2026-11-07'), (date '2026-11-08'), (date '2026-11-09')
),
bounds AS (
    SELECT cohort_date,
           CAST(cohort_date AS timestamp) AT TIME ZONE 'UTC' AS starts_at,
           CAST(cohort_date + 1 AS timestamp) AT TIME ZONE 'UTC' AS ends_at
    FROM days
),
counts AS (
    SELECT b.cohort_date,
           count(r.id) AS due,
           count(r.id) FILTER (
               WHERE r.paid_at < r.due_at + interval '48 hours'
           ) AS renewed
    FROM bounds b
    LEFT JOIN renewals r
        ON r.due_at >= b.starts_at AND r.due_at < b.ends_at
    WHERE b.ends_at + interval '48 hours' <= timestamptz '2026-11-11 00:00+00'
    GROUP BY b.cohort_date
)
SELECT cohort_date, due, renewed,
       round(100.0 * renewed / nullif(due, 0), 1) AS renewal_pct
FROM counts
ORDER BY cohort_date;

Плановая дата продления

Подписок к продлению

Продлили

Доля

7 ноября

3

2

66,7%

8 ноября

2

1

50,0%

За 7 ноября засчитались предоплата по записи 101 и оплата после окончания периода по записи 103. Платёж по записи 102 прошёл ровно через 48 часов и оказался вне окна. За 8 ноября платёж по записи 105 прошёл за час до границы. Если в закрытом дне вообще не было подписок к продлению, LEFT JOIN сохранит этот день, а доля будет NULL. В отчёте такую строку можно подписать «не было подписок к продлению».

В рабочем отчёте границу полноты лучше брать из загрузки данных. Если сейчас 11 ноября, но платежи загружены только до полудня 10 ноября, 8 ноября ещё рано выводить как завершённый день.

Сравнивайте дни, когда у всех подписок в группе уже прошло 48 часов с планового срока продления и данные об оплатах за это время загружены. Рядом с процентом показывайте, сколько подписок продлили и сколько всего ждали продления. Так 50% из двух подписок легко отличить от 50% из двух тысяч.

Документация PostgreSQL по агрегатам и FILTER