Что гарантирует язык
- Программы всегда завершаются
- Никакой плавающей точки: деньги — точная 128-битная арифметика, переполнение — ошибка, а не тихое округление
- Распределение точное: части всегда дают в сумме целое
- Одни и те же входы всегда дают один и тот же результат
- Право проверяется до того, как выполнится тело команды
Каждый пример ниже — настоящий 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) может охранять переход любого вида.
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 и кредит
- Свёртка счетов, банковские файлы и сроки лицензий
- Процессы заказов и права на строки
- Складские процессы
Короткие программы — это приятно, но число строк — самый слабый довод. Настоящая разница — в гарантиях: точные деньги, программы, которые завершаются, и один и тот же ответ каждый раз.