Оглавление13 разделов
Изменение условий оффера нельзя хранить как перезапись одной JSON-записи. Каждая конверсия должна однозначно ссылаться на редакцию, действовавшую в выбранный бизнес-момент. Для этого условия становятся неизменяемыми версиями с effective interval, checksum, источником и журналом утверждения. Позднее событие не должно получить сегодняшние правила только потому, что пришло сегодня.
Главный инвариантДля одного offer_id и scope в любой момент времени существует не более одной применимой версии. Опубликованная версия неизменяема; исправление создаёт новую запись.
Разделите три времени
- occurred_at — когда произошло квалифицирующее событие
- received_at — когда система его получила
- decided_at — когда принято коммерческое решение
Версия условий выбирается по времени, определённому договором. Часто это occurred_at, но для отдельных моделей может использоваться click_at, registration_at или начало расчётного периода. Выбор фиксируется в offer contract и не меняется от удобства отчёта.
Immutable snapshot
Snapshot содержит не только payout. В него входят модель, валюта, квалифицирующее действие, hold, caps, допустимые источники, GEO, reason codes, правила атрибуции и ссылки на первичный документ. Нормализованные поля помогают расчёту, а полный исходный документ сохраняет доказательство контекста.
{
"offer_id": "offer-stable-id",
"terms_version": "terms-2026-08-01-01",
"scope": {
"geo": "documented-geo",
"traffic_source": "documented-source",
"commercial_model": "CPA"
},
"effective_from": "2026-08-01T00:00:00Z",
"effective_to": null,
"qualification_event": "documented-event",
"currency": "documented-iso-code",
"source_url": "replace-with-primary-document",
"source_document_hash": "sha256:replace-with-calculated-hash",
"approved_by": ["commercial-owner", "compliance-owner"]
}Пример показывает структуру и намеренно не содержит ставки или реального GEO. В production все documented/replace поля должны быть заполнены первичными данными до активации.
Полуоткрытые интервалы
Используйте границы [from, to): начало включено, конец исключён. Тогда версия A, заканчивающаяся ровно в 12:00, и версия B, начинающаяся в 12:00, не пересекаются и не оставляют щель. Timestamp хранится с timezone; текст «с понедельника» сначала превращается в однозначный instant по согласованной зоне.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE offer_terms_versions (
offer_id text NOT NULL,
scope_key text NOT NULL,
terms_version text PRIMARY KEY,
valid_during tstzrange NOT NULL,
terms_document jsonb NOT NULL,
document_hash text NOT NULL,
source_url text NOT NULL,
approved_at timestamptz NOT NULL,
approved_by text[] NOT NULL,
EXCLUDE USING gist (
offer_id WITH =,
scope_key WITH =,
valid_during WITH &&
)
);PostgreSQL range types представляют интервалы, а exclusion constraint запрещает пересечение для одинакового offer_id и scope_key. Если инфраструктура другая, тот же инвариант реализуется транзакционной проверкой и тестами, но не только проверкой интерфейса.
Scope нельзя прятать в комментарии
Одна версия может отличаться по GEO, источнику, модели оплаты или группе паблишеров. Scope нормализуется в стабильный ключ или отдельные измерения. Свободный текст «особые условия для команды» невозможно надёжно сопоставить с событием и проверить на пересечение.
Привязка события к версии
SELECT e.conversion_id,
v.terms_version,
v.document_hash
FROM conversion_events AS e
JOIN offer_terms_versions AS v
ON v.offer_id = e.offer_id
AND v.scope_key = e.scope_key
AND v.valid_during @> e.occurred_at
WHERE e.conversion_id = :conversion_id;Если запрос возвращает ноль строк, событие не получает «ближайшую» версию автоматически: оно попадает в quarantine. Две строки означают нарушение инварианта. Обе ситуации требуют исправления данных, а не эвристики.
Закрепите terms_version в событии решения
После сопоставления terms_version записывается в conversion decision и финансовое движение. Повторный расчёт использует сохранённую версию и hash. Это защищает от изменения справочника и позволяет доказать, почему одинаковые события в разные даты получили разные решения.
Новая версия: процесс публикации
- Получить первичный документ и effective moment.
- Сравнить с текущей версией на уровне нормализованных полей и текста.
- Определить scope и список затронутых кампаний.
- Проверить, что новый интервал не пересекает существующий.
- Рассчитать impact для открытых кликов и pending-конверсий.
- Получить утверждение владельцев.
- Атомарно закрыть старый интервал и открыть новый.
- Разослать машинное событие terms_version_activated.
- Проверить первые сопоставления и отсутствие quarantine.
Diff, пригодный для решения
- Изменившееся поле и старое/новое значение
- Источник и hash обеих редакций
- Effective time и timezone
- Затронутые GEO, источники, кампании и когорты
- Изменение payout, cap, hold, qualification или attribution
- Нужна ли остановка доставки или только обновление текста
- Влияние на уже созданные pending события
- Владелец проверки и план отката
SELECT e.campaign_id,
count(*) AS open_conversions,
min(e.occurred_at) AS oldest_event,
max(e.occurred_at) AS newest_event
FROM conversion_events AS e
LEFT JOIN conversion_current_status AS s
ON s.conversion_id = e.conversion_id
WHERE e.offer_id = :offer_id
AND e.scope_key = :scope_key
AND e.occurred_at < :new_effective_from
AND coalesce(s.status, 'pending') = 'pending'
GROUP BY e.campaign_id
ORDER BY open_conversions DESC;Этот отчёт показывает, сколько событий после активации продолжат созревать по старой версии. Их нельзя автоматически пересчитать по новой только из-за позднего decided_at.
Ретроактивное изменение
Если партнёр сообщает изменение задним числом, система создаёт correction request, а не редактирует valid_during молча. Запрос хранит основание, источник, объявленный интервал и предполагаемый impact. После согласования формируются новые status_version или ledger adjustments со ссылкой на correction_id.
Исторический snapshot остаётся. Отчёт может показать «как было решено» и «как скорректировано». Это важно для разбирательства: пересчитанная сумма без следа выглядит как исходное условие, которым она не была.
Caps и pacing при смене редакции
Нужно заранее определить, относится cap к календарному дню, версии или непрерывному офферу. Если версия меняется внутри дня, обнуление счётчика может вызвать перелив, а перенос без основания — недолив. Counter policy хранится в документе условий и тестируется на границе effective_from.
Кеши и доставка
CMS, tracker и отчёты могут обновиться не одновременно. Событие активации содержит terms_version и effective_from; потребители подтверждают применённую версию. До достижения согласованного состояния новые кампании не запускаются. Cache TTL не должен быть длиннее допустимого окна рассинхронизации.
Тесты инвариантов
- Две версии одного scope не пересекаются
- Событие на нижней границе получает новую версию
- Событие ровно на верхней границе не получает старую
- Поздно доставленное событие использует occurred_at
- Неизвестный scope попадает в quarantine
- Повторная доставка сохраняет terms_version
- Correction создаёт новую историю и ledger entry
- Hash опубликованного snapshot не меняется
Не используйте updated_at как версию условийВремя записи в базу не сообщает, когда правило начало действовать. Нужны отдельные effective time, terms_version и источник.
Источники и границы применимости
Вывод
Версионирование условий делает расчёт воспроизводимым: immutable snapshot хранит доказательство, полуоткрытый интервал выбирает редакцию, exclusion constraint защищает от двойного применения, а terms_version остаётся в статусе и ledger. Изменение задним числом становится видимой корректировкой, а не переписанной историей.
Станьте партнёром и начните работать
Перейдите в партнёрскую программу, изучите актуальные условия и выберите подходящий формат сотрудничества.
СТАТЬ ПАРТНЁРОМ
