К содержанию
Общее · Разработчикам

Единый баланс Lovsel в плагинах

Резерв под заказ, списание, снятие резерва и возврат через ctx.wallet; ключи поставщика на сервере.

Содержание

У продавца в 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 не приходят:

python
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:

python
api_key = ctx.secrets.require("SUPPLIER_API_KEY")   # понятная ошибка, если ключ не задан
token = ctx.secrets.get("EXTRA_TOKEN")              # None, если нет

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

Баланс

python
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) → списание → результат. Это рекомендуемый способ.

python
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 — скольконичего: продавец уже получил уведомление; после пополнения придёт событие
failedbuy бросил 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 ничего не покупает:

  1. операция получает статус waiting_funds, заказ ждёт;
  2. продавец получает уведомление в центре событий и Telegram: сколько не хватает и кнопка «Пополнить баланс» с этой суммой;
  3. после пополнения (или когда освободится другой резерв) Lovsel сам резервирует деньги — по очереди, начиная со старых заказов — и присылает плагину событие wallet.operation с reason="reserved";
  4. плагин продолжает тот же заказ через 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

python
@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().

python
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.

Возврат

Если поставщик вернул деньги за уже списанную закупку, верните их продавцу:

python
op = ctx.wallet.find(order_id, marketplace="ggsel")
op.refund()                                   # всё: закупка + комиссия
op.refund(30, reason="Частичный возврат поставщика", key="ret-1")   # часть

Вернуть больше списанного нельзя. Повторный refund() с тем же key ничего не возвращает второй раз.

Пример для GGsel

python
@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

python
@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.

python
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.

Проверьте у себя как минимум: успешную закупку, отказ поставщика (резерв снят), таймаут (деньги в резерве, второй покупки нет), нехватку денег и повторное событие по тому же заказу.