Содержание
У продавца в Lovsel один внутренний баланс на всё: тарифы, платные плагины и автоматические закупки, которые плагины делают по заказам. Плагин может оплатить с этого баланса покупку у поставщика — например, купить ключ или код под оплаченный заказ GGsel или FunPay и выдать его покупателю.
Общие правила создания плагина — в руководстве «Как создать плагин». Здесь — только работа с балансом.
Главное правило: плагин не трогает деньги сам
Плагин не имеет прямого доступа к деньгам продавца. Он не меняет баланс, не видит таблиц с деньгами и не может списать произвольную сумму. Плагин только просит сервер Lovsel выполнить один из шагов, а сервер сам решает, можно ли:
- резерв делается только под конкретный заказ этого продавца (GGsel или FunPay) — без заказа резерва нет;
- каждая операция привязана к продавцу, плагину и заказу. Чужие операции плагин не видит;
- комиссию Lovsel считает сервер по ставке из админки — плагин её не задаёт;
- сумма одной закупки ограничена лимитом сервиса;
- баланс никогда не уходит в минус: зарезервированные деньги нельзя потратить ни на тариф, ни на плагин, ни на другую закупку;
- каждый шаг выполняется один раз: повтор события, перезапуск сервера или второй процесс не спишут деньги дважды и не купят товар второй раз.
Прямые обращения к деньгам в обход SDK — к внутренним методам баланса или таблицам wallets, wallet_transactions, wallet_operations — проверка Lovsel отмечает как опасные, и такой плагин не получит отметку «Проверен Lovsel».
Как устроена операция
Каждая закупка — одна операция по заказу. Сначала деньги откладываются, потом списываются или возвращаются:
заказ → резерв → внешняя операция (покупка у поставщика) → списание → выдача покупателю
заказ → резерв → ошибка поставщика → снятие резерва (деньги снова доступны полностью)| Статус | Что значит | Деньги |
|---|---|---|
waiting_funds | денег не хватает, ждём пополнения | не тронуты |
reserved | сумма отложена под заказ | в резерве |
processing | плагин начал покупку у поставщика | в резерве |
captured | покупка прошла, сумма списана | списаны (закупка + комиссия) |
released | покупки не было, резерв снят | вернулись в доступные |
cancelled | ожидание пополнения отменено | не тронуты |
refunded | деньги после списания возвращены полностью | вернулись на баланс |
Продавец видит каждую операцию в «Баланс и тарифы»: в резерве или списано, сколько ушло поставщику, сколько — комиссия Lovsel, какой плагин, какой заказ и что вернули.
Пример: закупка 100 ₽ при комиссии 2% — в резерв уходит 102 ₽, после покупки списывается 100 ₽ «Закупка у поставщика» и 2 ₽ «Комиссия Lovsel».
Разрешение и ключи сервисов
Добавьте разрешение wallet — без него ctx.wallet выбрасывает PermissionDenied, а события wallet.operation не приходят:
MANIFEST = {
"id": "studio.supplier-purchase",
"name": "Автозакупка у поставщика",
"version": "1.0.0",
"description": "Оплаченный заказ → покупка у поставщика за баланс Lovsel → выдача покупателю.",
"marketplaces": ["ggsel", "funpay"],
"permissions": ["orders.read", "chats.write", "storage", "network", "wallet", "notifications"],
}Разрешение wallet видно покупателю плагина до покупки: «Оплата закупок по заказам с баланса Lovsel».
Ключ API поставщика хранится на сервере Lovsel. Администратор задаёт его в карточке плагина («Админ-панель → Маркетплейс → плагин → Ключи сервисов»), а плагин читает через ctx.secrets:
api_key = ctx.secrets.require("SUPPLIER_API_KEY") # понятная ошибка, если ключ не задан
token = ctx.secrets.get("EXTRA_TOKEN") # None, если нетПродавцы и разработчик значение ключа не видят, в браузер оно не передаётся. Ключи получает только плагин из магазина Lovsel — собственный загруженный файл продавца их не видит. Не показывайте ключ на страницах плагина и не пишите его в журнал.
Баланс
b = ctx.wallet.balance()
# {"available": 350.0, "reserved": 102.0, "total": 452.0, "waiting": 0,
# "shortfall": 0.0, "fee_percent": 2.0, "enabled": True, "currency": "RUB"}
q = ctx.wallet.quote(100)
# {"amount": 100.0, "fee": 2.0, "total": 102.0, "fee_percent": 2.0,
# "available": 350.0, "enough": True, "shortfall": 0.0}available — сколько можно потратить сейчас (баланс минус резервы). Суммы — в рублях.
Закупка одной функцией
ctx.wallet.purchase() делает всю цепочку по безопасной схеме: резерв → buy(op) → списание → результат. Это рекомендуемый способ.
from lovsel_sdk import PurchaseFailed
def buy(op):
# op.id — номер операции Lovsel: передайте поставщику как номер своего заказа
# или ключ идемпотентности, чтобы потом найти покупку.
r = requests.post(API + "/orders", json={"item_id": item_id, "client_order_id": op.id},
headers={"Authorization": "Bearer " + ctx.secrets.require("SUPPLIER_API_KEY")}, timeout=20)
if r.status_code in (400, 404, 409, 422):
raise PurchaseFailed(r.json().get("message") or "Нет в наличии") # покупки точно не было
r.raise_for_status() # 5xx, таймаут — исход неизвестен
data = r.json()
return {"ref": str(data["id"]), "goods": data["goods"]} # ref — номер у поставщика
def check(op):
# Была ли покупка по этой операции? Словарь — была, None — не было, ошибка — пока неизвестно.
r = requests.get(API + "/orders", params={"client_order_id": op.id}, timeout=20)
r.raise_for_status()
items = r.json().get("items") or []
return {"ref": str(items[0]["id"]), "goods": items[0]["goods"]} if items else None
res = ctx.wallet.purchase(order_id, 100, buy=buy, check=check, marketplace="ggsel",
title="Steam 100 ₽", supplier="Поставщик", details={"chat_id": chat_id})Что вернёт purchase (res.status):
| Статус | Что произошло | Что делать плагину |
|---|---|---|
done | куплено и списано, res.result — ответ buy | выдать покупателю |
waiting_funds | не хватает денег, res.shortfall — сколько | ничего: продавец уже получил уведомление; после пополнения придёт событие |
failed | buy бросил PurchaseFailed или check не нашёл покупку — резерв снят | сообщить продавцу (res.error) |
unknown | ответа поставщика нет — деньги в резерве | ничего: повторной покупки не будет, Lovsel пришлёт событие проверки |
in_progress | этот заказ прямо сейчас обрабатывает другой вызов | ничего |
closed | операция по заказу уже закрыта без списания | повторить можно с retry_failed=True |
Повторный вызов purchase по тому же заказу не создаёт новую операцию: он продолжает существующую. Если товар уже куплен, вернётся done с прежним результатом — без второй покупки и второго списания.
Несколько закупок по одному заказу (например, два разных товара) разделяйте параметром key: purchase(order_id, 50, key="item-1", ...), purchase(order_id, 70, key="item-2", ...).
Нехватка денег и продолжение заказа
Если доступно меньше, чем нужно (закупка + комиссия), Lovsel ничего не покупает:
- операция получает статус
waiting_funds, заказ ждёт; - продавец получает уведомление в центре событий и Telegram: сколько не хватает и кнопка «Пополнить баланс» с этой суммой;
- после пополнения (или когда освободится другой резерв) Lovsel сам резервирует деньги — по очереди, начиная со старых заказов — и присылает плагину событие
wallet.operationсreason="reserved"; - плагин продолжает тот же заказ через
ctx.wallet.resume(op, ...).
Ожидание пополнения длится 3 дня, неиспользованный резерв — 24 часа; потом Lovsel отменяет их сам, деньги остаются на балансе. Продавец может отменить ожидание или резерв вручную в «Баланс и тарифы».
Зависший запрос к поставщику
Если buy не дождался ответа (таймаут, обрыв, ошибка 5xx), неизвестно, прошла ли покупка. Поэтому Lovsel:
- не снимает резерв и не даёт купить второй раз — повторный
purchaseпо заказу вернётin_progressилиunknown, но не вызоветbuy; - когда истечёт время на ответ (
lease, по умолчанию 120 секунд), присылает событиеwallet.operationсreason="check"; resumeвызывает вашcheck(op): нашлась покупка — списание и выдача, не нашлась — резерв снимается.
Если check недоступен или поставщик так и не ответил, операция остаётся в резерве. Продавец видит её в истории, а администратор Lovsel может закрыть её вручную после проверки у поставщика.
Событие wallet.operation
@app.on("wallet.operation")
def on_wallet(ctx, event):
op = event["operation"] # WalletOperation этого плагина
# event["reason"]: "reserved" — деньги появились, "check" — пора проверить зависшую закупку
res = ctx.wallet.resume(op, buy=make_buy(op), check=make_check(op))
if res.ok:
deliver(ctx, op, res.result)Событие получает только плагин, который создал операцию, и только с разрешением wallet. В событии также есть marketplace и order_id.
Ручное управление
Если нужна своя логика, шаги доступны по отдельности. Порядок всегда один: резерв, begin(), внешняя операция, затем capture() или release().
op = ctx.wallet.reserve(order_id, 100, marketplace="funpay", title="Ключ", supplier="Поставщик")
if op.waiting:
return # ждём пополнения
if op.reserved and op.begin(lease=120): # True получит только один вызов
try:
out = supplier.buy(item_id, op.id)
except SupplierRefused as exc:
op.release(str(exc)) # покупки не было — деньги снова доступны
else:
op.capture(external_ref=out["id"], result={"goods": out["goods"]}) # окончательное списание
deliver(ctx, op, op.result)| Метод | Что делает |
|---|---|
ctx.wallet.reserve(order_id, amount, *, marketplace, key, title, supplier, details, retry) | резерв под заказ; повтор вернёт ту же операцию |
ctx.wallet.get(op_id), ctx.wallet.find(order_id, marketplace=, key=) | операция этого плагина |
ctx.wallet.pending(), ctx.wallet.history(limit) | незавершённые и последние операции плагина |
op.begin(lease) | начать внешнюю операцию: True — можно покупать, False — уже начал кто-то другой |
op.capture(external_ref, result) | покупка прошла — списать резерв; повтор ничего не списывает |
op.release(reason) | покупки не было — снять резерв полностью |
op.claim_check(lease) | взять на себя проверку зависшей закупки |
op.refund(amount=None, reason, key) | вернуть деньги после списания: всё или часть |
op.mark_delivered() | отметить выдачу покупателю, чтобы не выдать дважды |
op.refresh() | перечитать состояние |
Поля операции: id, status, marketplace, order_id, key, title, supplier, amount (закупка), fee (комиссия), total (закупка + комиссия), refunded, external_ref, details (ваши данные из reserve), result (то, что передано в capture), delivered и флаги waiting, reserved, processing, captured, closed.
Отказы сервера приходят исключением WalletError с полями code и message: например, order_not_found (заказа нет в магазине продавца), too_large (больше лимита), disabled (администратор выключил оплату закупок), not_reserved, not_captured.
Возврат
Если поставщик вернул деньги за уже списанную закупку, верните их продавцу:
op = ctx.wallet.find(order_id, marketplace="ggsel")
op.refund() # всё: закупка + комиссия
op.refund(30, reason="Частичный возврат поставщика", key="ret-1") # частьВернуть больше списанного нельзя. Повторный refund() с тем же key ничего не возвращает второй раз.
Пример для GGsel
@app.on("new_order")
def on_order(ctx, event):
order = event["order"]
link = ctx.storage.get("links", {}).get(str(order["product_id"]))
if not link:
return
buy, check = supplier_calls(ctx, link["item_id"])
res = ctx.wallet.purchase(order["id"], link["cost"], marketplace="ggsel", buy=buy, check=check,
title=link["title"], details={"item_id": link["item_id"], "chat_id": order["chat_id"]})
if res.ok and not res.operation.delivered:
ctx.ggsel.send_message(order["chat_id"], "Ваш товар: " + res.result["goods"])
res.operation.mark_delivered()Пример для FunPay
@app.on("funpay.new_order")
def on_order(ctx, event):
order = event["order"]
if order.get("status") != "paid":
return
link = find_link_by_title(ctx, order["title"])
if not link:
return
buy, check = supplier_calls(ctx, link["item_id"])
res = ctx.wallet.purchase(order["id"], link["cost"], marketplace="funpay", buy=buy, check=check,
title=link["title"], details={"item_id": link["item_id"], "chat_id": order["chat_id"]})
if res.ok and not res.operation.delivered:
ctx.funpay.send_message(order["chat_id"], "Ваш товар: " + res.result["goods"])
res.operation.mark_delivered()У плагина для одной площадки параметр marketplace можно не указывать. Плагину для обеих площадок он нужен.
Полный пример — «Автозакупка у поставщика» (docs/examples/supplier_purchase.py): связки товаров, закупка за баланс, ожидание пополнения, проверка зависших закупок, выдача покупателю и страница с историей закупок. Поставщик в нём условный — замените адреса и поля ответа под API своего сервиса.
Проверка без сервера
Тестовый стенд SDK работает с настоящей логикой баланса на временной базе: резерв, списание, комиссия, повторы и нехватка денег ведут себя так же, как в Lovsel.
from lovsel_sdk.testing import load_plugin
plugin = load_plugin("my_plugin.py", balance=50, fee_percent=2,
secrets={"SUPPLIER_API_KEY": "test"},
orders=[{"id": "9001", "product_id": 101}])
plugin.emit("new_order", {"order": {"id": "9001", "product_id": 101, "chat_id": 9001}})
assert plugin.wallet_operations()[0].status == "waiting_funds" # 102 ₽ > 50 ₽, покупки не было
plugin.topup(100) # пополнение: резерв сделается сам и придёт wallet.operation
assert plugin.wallet_operations()[0].status == "captured"
assert plugin.balance()["available"] == 48.0 # 150 − 102Резерв возможен только под заказ, который есть на стенде: передайте его в orders (GGsel) или funpay={"orders": [...]}. Запросы к настоящему поставщику на стенде замените заглушкой — например, подмените buy и check в тесте через monkeypatch.
Проверьте у себя как минимум: успешную закупку, отказ поставщика (резерв снят), таймаут (деньги в резерве, второй покупки нет), нехватку денег и повторное событие по тому же заказу.