Cambium: один язык бизнес-логики

Бизнес-правила обычно живут в пяти местах: в коде, таблицах, экранах настроек, формулах отчётов и головах людей. Cambium собирает их в одном месте, где их можно прочитать, проверить и проиграть заново.

Что гарантирует язык

  • Программы всегда завершаются
  • Никакой плавающей точки: деньги — точная 128-битная арифметика, переполнение — ошибка, а не тихое округление
  • Распределение точное: части всегда дают в сумме целое
  • Одни и те же входы всегда дают один и тот же результат
  • Право проверяется до того, как выполнится тело команды
Одно описание на Cambium — много результатовОдно описание на Cambiumправила · статусы · права · настройкиПроверкидо запускаПрава — докаждой командыСтраница настроекс пределамиСхема статусови доска задачТипизированный APIи клиентыЖурнал и ответ«почему столько»
Из одного описания получаются проверки, права, страница настроек, схема статусов, типизированный API и журнал.

Каждый пример ниже — настоящий Cambium и в таком виде проходит проверку языка.

Пример 1: пакет и его настройки

Пакет называет себя, свою версию и пакеты, на которые опирается:

[package]
name = "partner_payouts"
version = "1.4.0"
kind = "package"

[requires]
ledger = "1"
sales = "1"

Затем объявляет, что организация может настроить, — каждую настройку с типом, пределами и значением по умолчанию:

enum PayoutMode { Manual, Automatic }

config payout {
    rate: ratio      { range: 0.01..=0.3, step: 0.001, default: 0.05 },
    wait: duration   { range: 1d..=30d, default: 3d },
    minimum: ratio   { range: 0..=1000, default: 50 },
    mode: PayoutMode { default: Manual },
} ensure mode is Manual or wait >= 7d

pub config referral {
    inviter_points: ratio  { range: 0..=10000, default: 500 },
    friend_discount: ratio { range: 0..=0.5, default: 0.1 },
    window: duration       { default: 180d },
}

Из этих строк платформа строит страницу настроек с пределами, право на её правку, проверку ввода и журнал изменений с автором и датой. Последняя строка — правило между полями: автоматические выплаты ждут не меньше недели.

Пример 2: лояльность B2C — кэшбэк, бонусы, пределы

Кэшбэк по сумме чека, бонус за отзывы не чаще пяти раз в месяц и предел оплаты баллами:

config redeem {
    share_max: ratio { range: 0..=1, default: 0.3 },
}

/// Кэшбэк по сумме чека: срабатывает первая верная строка.
rule tiered_cashback on sales::receipt.closed as receipt {
    params {
        bonus_from: ratio { default: 200 },
        bonus_points: ratio { default: 500 },
        high_from: ratio { default: 100 },
        high_rate: ratio { range: 0..=0.5, default: 0.1 },
        mid_from: ratio { default: 50 },
        mid_rate: ratio { default: 0.05 },
        min_total: ratio { default: 15 },
        base_rate: ratio { default: 0.03 },
    }
    when receipt.total >= params.bonus_from => points::bonus(receipt.member, params.bonus_points, lot: LotSpec {
        pool: Base,
        expires: None,
    });
    when receipt.total > params.high_from => points::rate(receipt, params.high_rate);
    when receipt.total >= params.mid_from => points::rate(receipt, params.mid_rate);
    when receipt.total < params.min_total => skip;
    else => points::rate(receipt, params.base_rate);
}

/// Проверенный отзыв с фото даёт 50 баллов, не больше 5 раз в месяц.
rule review_bonus on reviews::published as published where published.verified and published.has_photo {
    params {
        points: ratio { default: 50 },
        max_per_month: u64 { default: 5 },
    }
    progress per published.member within calendar_month { n: u64 }
    when progress.n < params.max_per_month => points::bonus(published.member, params.points, lot: LotSpec {
        pool: Promo,
        expires: None,
    }), progress.n.inc(1);
    else => skip;
}

/// Баллами можно оплатить не больше 30 % чека.
policy points::redeem(r: sales::Receipt, amount: ratio) -> bool {
    amount <= r.total * config.redeem.share_max
}

Строки проверяются сверху вниз, срабатывает первая верная. Месячный предел считается для каждого участника и начинается заново каждый календарный месяц. Каждое число — настройка, которую маркетинг меняет без программиста.

Пример 3: условия B2B — объёмные ступени и кредит

Квартальная объёмная шкала с выплатой кредит-нотой, кредитный лимит, растущий после года своевременных оплат, и запрет предложений, пока дилер превышает лимит:

query quarter_volume(d: Id<b2b::Dealer>, q: period) -> ratio {
    b2b::invoice.filter(|i| i.dealer == d and i.closed_at in q).sum(|i| i.total)
}

/// Объёмная шкала: от 500 000 за квартал 4 %, от 1 000 000 — 6 %, выплата кредит-нотой.
rule retro_scale every quarter for d in b2b::dealer {
    params {
        tier1_from: ratio { default: 500_000 },
        tier1: ratio { range: 0..=0.2, default: 0.04 },
        tier2_from: ratio { default: 1_000_000 },
        tier2: ratio { range: 0..=0.2, default: 0.06 },
    }
    let volume = quarter_volume(d.id, period.previous_quarter());
    when volume >= params.tier2_from => ledger::credit_note(d.account, volume * params.tier2, key: (RetroScale, d.id, period.previous_quarter()));
    when volume >= params.tier1_from => ledger::credit_note(d.account, volume * params.tier1, key: (RetroScale, d.id, period.previous_quarter()));
}

/// Год счетов NET 30, оплаченных в срок, поднимает кредитный лимит на 25 %.
rule flawless_net30 every year for d in b2b::dealer {
    params {
        raise: ratio { range: 0..=1, default: 0.25 },
    }
    when b2b::invoice.filter(|i| i.dealer == d.id and i.due_at in period.previous_year()).all(|i| i.paid_at is Some(p) and p <= i.due_at)
        => b2b::dealer.set_credit_limit(d.id, d.credit_limit * (1 + params.raise), key: (CreditRaise, d.id, period.previous_year()));
}

/// Сверх кредитного лимита — ни скидок, ни бонусов, пока баланс не вернётся.
policy offers_allowed(d: b2b::Dealer) -> bool {
    b2b::balance(d.id) <= d.credit_limit
}

У каждой кредит-ноты есть ключ — дилер и квартал, — поэтому повторный запуск никогда не заплатит дважды.

Пример 4: бонусный пул

Таблица решений задаёт вес партнёра по уровню и объёму; срабатывает первая подходящая строка. Запрос считает пул: оплаченные заказы за период, умноженные на долю, действовавшую на начало периода.

/// Вес партнёра в пуле: уровень и личный оборот за период.
decision weight(tier: Tier, volume: ratio) -> u8 hit first {
    | tier | volume | => weight |
    | crm::Tier.Platinum | >= 10_000 | => 3 |
    | crm::Tier.Gold | >= 5_000 | => 2 |
    | crm::Tier.Gold | _ | => 1 |
    | _ | >= 1_000 | => 1 |
    | _ | _ | => 0 |
}

/// Пул: оплаченный оборот периода, умноженный на долю, действовавшую на его начало.
pub query pool_total(p: period) -> ratio = (sales::Order where status is Paid and paid_at in p sum(total)) * config.pool.as_of(p.start).share;

Команда, которая выплачивает пул, требует права, выполняется не больше одного раза за период и делит пул до цента — части всегда складываются в пул:

/// Раздаёт пул до цента: части всегда складываются в пул, одной группой переводов.
pub cmd distribute(p: period) -> Result<Id<ledger::Batch>, ledger::TransferError> permission bonus.distribute idempotent by p {
    let members = participants(p);
    let parts = allocate(pool_total(p), members.map(|m| m.w), 2);
    ledger::batch(members.zip(parts).map(|(m, amt)| ledger::leg(ledger::Party.Kind {
        holder: "bonus_pool",
        kind: "pool",
    }, ledger::Party.Account { account: m.bonus_account }, ledger::amount(amt, ledger::Asset.Points {
        pool: "bonus",
    }), bonus.pool { period: p, member: m.id })))
}

Пример 5: как может меняться статус

Машина статусов перечисляет разрешённые переходы между состояниями. Для каждого перехода она говорит, как он может произойти: по событию или по праву — человек переводит вручную, например кнопкой в интерфейсе. Выбор делается для каждого перехода отдельно. Условие (ensure) может охранять переход любого вида.

Автомат заказа: одни переходы срабатывают по событию, другие делает человек с правомDraftPlacedPaidShippedCancelledorder.cancelRefundedorder.refundorder.placepayment.receivedorder.shipпо событиюпо праву
Состояния заказа. Сплошные стрелки срабатывают по событию; пунктирные переводит человек с правом.
machine Order.status {
    Draft  -> Placed    permission order.place
                        ensure lines.len() > 0 else (err.empty { order: id });
    Placed -> Paid      on payments::payment.received as p
                        where p.order == id;
    Paid   -> Shipped   permission order.ship { emit order.shipped { order: id }; }
    Paid   -> Refunded  permission order.refund;
    Placed -> Cancelled permission order.cancel;
}
  • Draft → Placed — вручную, с правом оформления, и только если в заказе есть строки; иначе переход отклоняется с причиной, называющей заказ
  • Placed → Paid — по событию: пришла оплата этого заказа
  • Paid → Shipped — вручную, с правом отгрузки; переход ещё и сообщает об отгрузке всем, кто её ждёт
  • Paid → Refunded и Placed → Cancelled — вручную, у каждого своё право

Так же устроена любая сущность. Здесь _ означает «из любого состояния»: участника можно заблокировать или приостановить при любом статусе, но вернуть приостановленного может только тот, у кого есть право:

machine Member.status {
    _         -> Banned    permission admin.ban;      // из любого состояния
    _         -> Suspended permission admin.suspend;
    Suspended -> Active    permission admin.resume;
}

Ручной переход называет только целевое состояние. Машина сама находит разрешённый переход из текущего состояния, проверяет право и условие и только тогда меняет статус:

pub cmd ship(o: Id<Order>) -> Result<(), err.empty> permission order.ship idempotent by o {
    Order.move(o, Shipped)?;          // ручной переход: целевое состояние; переход машина найдёт сама
    Ok(())
}

Чего нет в описании, не может произойти вовсе, какие бы права ни были у пользователя: никакое право не вернёт отгруженный заказ в черновик, потому что такого перехода нет. Прямая запись в поле статуса тоже отклоняется — любое изменение идёт через машину. Каждый переход ещё и поднимает событие смены статуса со старым и новым состоянием — для отчётов, уведомлений и аудита.

Пример 6: права и доверие сессии

Права объявляются один раз на пакет, группами. Каждое право ещё и указывает нижний уровень доверия сессии — от анонимной до квалифицированной подписи:

/// Права группами; у каждого — нижний уровень доверия сессии.
permissions {
    payout level strong {
        read level identified,
        edit,
        approve implies read,
    },
    all_regions,
    director level proven,
}

config payout {
    approve_limit: Amount<usd> { default: 10_000 },
}

entity Payout {
    region: Region,
    status: PayoutStatus,
    amount: Amount<usd> write permission payout.edit,
    note: string,
}

/// Утверждение — ручной переход: право и условие; выше лимита нужен ещё и директор.
machine Payout.status {
    Draft -> Approved permission if amount < config.payout.approve_limit { payout.approve } else { payout.approve and director }
                      ensure amount > 0 usd and note != "" else (err.payout.state);
}

/// Политика строк: пользователь видит только выплаты своих регионов.
policy payout.read(p: Payout, actor: Actor) -> bool {
    actor.regions.contains(p.region) or actor.has(all_regions)
}

pub query payouts_due() permission payout.read = Payout where status is Draft sum(amount);
  • Читать выплаты можно с установленной личностью; утверждать — со вторым фактором; праву директора нужна личность, проверенная по документу
  • Кто может утверждать, может и читать
  • Сумму можно менять только с правом правки
  • Выше заданного лимита для утверждения нужен ещё и директор
  • Политика строк ограничивает любой запрос регионами пользователя: в итоговом запросе выше нет своего фильтра, а он всё равно возвращает только то, что этому пользователю можно видеть
  • Утверждение — ручной переход: человек с правом переводит выплату в Approved, и только если сумма положительна и есть пояснение. Тот же переход можно было бы привязать к событию — это выбор для каждого перехода

Доверие сессии — отдельная от прав ось. Нет права — окончательное «нет». Нет нужного доверия — не отказ: в том же ответе приходит готовая форма «подтвердите более сильным фактором». Организация может поднять требуемый уровень, но не опустить.

Где применяется Cambium

  • Объёмы партнёрской сети, выплаты и бонусные пулы
  • Программы лояльности, условия B2B и кредит
  • Свёртка счетов, банковские файлы и сроки лицензий
  • Процессы заказов и права на строки
  • Складские процессы

Короткие программы — это приятно, но число строк — самый слабый довод. Настоящая разница — в гарантиях: точные деньги, программы, которые завершаются, и один и тот же ответ каждый раз.

Следующий шаг

Посмотрите на примере своего бизнеса

Запросите демо: покажем платформу на ваших процессах и договоримся, где будут жить ваши данные, — в нашем облаке или на ваших узлах.