sozidatel/drdengi-php-sdk

PHP SDK for the Drebedengi SOAP API.

Maintainers

Package info

github.com/sozidatel/drdengi-php-sdk

pkg:composer/sozidatel/drdengi-php-sdk

Transparency log

Statistics

Installs: 20

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.7.0 2026-07-16 08:37 UTC

This package is auto-updated.

Last update: 2026-07-16 08:37:26 UTC


README

PHP SDK для SOAP API Дребеденег.

SDK намеренно не генерирует PHP-классы из WSDL: в WSDL почти все полезные ответы описаны как anyType, поэтому публичный слой здесь доменный и типизированный, а legacy-массивы остаются на границе транспорта.

Изменения релизов перечислены в CHANGELOG.md, а важные шаги миграции — в UPGRADING.md. Политика совместимости описана в docs/versioning.md.

Установка

composer require sozidatel/drdengi-php-sdk

Для локальной разработки в этом репозитории:

composer install
composer test:unit
composer analyse

Настройка

use Soz\Drebedengi\Credentials;
use Soz\Drebedengi\ClientOptions;
use Soz\Drebedengi\DrebedengiClient;
use Soz\Drebedengi\Endpoint;
use Soz\Drebedengi\WsdlCache;

$credentials = new Credentials(
    apiId: getenv('DREB_API_ID'),
    login: getenv('DREB_LOGIN'),
    password: getenv('DREB_PASSWORD'),
);

$client = DrebedengiClient::fromCredentials(
    $credentials,
    options: new ClientOptions(
        timezone: new DateTimeZone('Europe/Podgorica'),
        connectTimeout: 10,
        readTimeout: 30.0,
        wsdlCache: WsdlCache::Memory,
    ),
);

По умолчанию SDK обращается только к основному SaaS-серверу Endpoint::RU_BASE_URI. Сервер можно передать как base URL или как объект Endpoint:

// Официальное ME-зеркало.
$meClient = DrebedengiClient::fromCredentials($credentials, Endpoint::ME_BASE_URI);

// Self-hosted установка с путём. SDK добавит /soap/dd.wsdl и /soap/.
$selfHostedClient = DrebedengiClient::fromCredentials(
    $credentials,
    'https://money.example.com/drebedengi',
);

SDK не добавляет скрытые fallback-серверы. Чтобы включить failover, передайте два или больше base URL в нужном порядке:

$client = DrebedengiClient::fromCredentials(
    $credentials,
    [Endpoint::RU_BASE_URI, Endpoint::ME_BASE_URI],
);

Успешно ответивший endpoint становится активным, пока инфраструктурный сбой не заставит SDK выбрать другой. Read-only вызов можно повторить на следующем endpoint. Если до первой операции записи endpoint ещё не выбран чтением, SDK сначала выполняет безопасный getAccessStatus. Саму запись он отправляет только один раз: неоднозначный сбой после отправки приводит к AmbiguousMutationException и не вызывает автоматический повтор. Credentials передаются каждому endpoint из явно заданного списка, поэтому добавляйте только доверенные серверы.

SDK переопределяет SOAP location, потому что официальный WSDL может указывать другой адрес сервиса. Параметры location и exceptions зарезервированы транспортом: переданные через soapOptions значения для них игнорируются, чтобы credentials не ушли на другой endpoint, а SoapFault всегда проходил через классификацию исключений SDK. Остальные SOAP options остаются настраиваемыми и имеют приоритет над соответствующими полями ClientOptions. Передача null в connectTimeout или readTimeout отключает типизированное значение.

Дребеденьги передают даты операций как YYYY-MM-DD HH:MM:SS без timezone. SDK не нашел timezone в SOAP-методах аккаунта, поэтому timezone аккаунта нужно задавать явно через ClientOptions. Если не задать, будет использована date_default_timezone_get().

Чтение данных

$places = $client->places()->list();
$categories = $client->categories()->list();
$sources = $client->sources()->list();
$currencies = $client->currencies()->list();
$tags = $client->tags()->list();
$balance = $client->balance()->list();

У всех пяти финансовых справочников — счетов, категорий, источников, валют и тегов — одинаковые точечные методы:

$categories = $client->categories()->byIds(['10', '20']);
$category = $client->categories()->find('10');    // Category|null
$category = $client->categories()->require('10'); // Category или InvalidArgumentException

byIds() проверяет положительные ID, удаляет повторы и при пустом списке не делает SOAP-вызов. Неизвестные ID сервер просто не включает в ответ.

Остатки на выбранную дату и дополнительные опции:

use Soz\Drebedengi\Model\BalanceQuery;

$balance = $client->balance()->list(
    BalanceQuery::at(new DateTimeImmutable('2026-07-14'))
        ->includeHidden()
        ->includeZero()
        ->subtractAccumulations()
        ->subtractDebts(),
);

restDate форматируется в timezone из ClientOptions. subtractAccumulations() вычитает зарезервированные накопления, subtractDebts() — долги, а includeHidden() и includeZero() добавляют скрытые и нулевые счета. Legacy-массив параметров для balance()->list() пока поддерживается для обратной совместимости.

Места хранения:

$places = $client->places()->list();      // счета и папки, отсортированы по sort
$accounts = $client->places()->accounts(); // только type=4, можно использовать в операциях
$folders = $client->places()->folders();   // только type=9
$placeTree = $client->places()->tree(includeHidden: false);

foreach ($placeTree as $node) {
    echo $node->place->name;
    echo $node->place->canHaveTransactions() ? " можно использовать в операциях\n" : " только папка\n";
}

type=4 — реальный счет, type=9 — папка для дерева. Отрицательные parent_id, например -3, сохраняются как systemParentId, а не как обычная папка. Для -3 счет остается видимым и принимает операции, но в UI попадает в группу Скрытые суммы, а его баланс исключается из Итого; это можно проверить через $place->isExcludedFromTotal().

Плоский список категорий уже отсортирован по sort. Для дерева:

$tree = $client->categories()->tree(includeHidden: false);

foreach ($tree as $node) {
    echo $node->category->name . "\n";
    foreach ($node->children as $child) {
        echo "  " . $child->category->name . "\n";
    }
}

Для <select>:

foreach ($client->categories()->options(includeHidden: false) as $option) {
    echo "<option value=\"{$option->id}\">{$option->label}</option>";
}

CRUD финансовых справочников

Создание и частичное обновление возвращают уже перечитанный типизированный DTO, а не сырой SOAP-массив:

use Soz\Drebedengi\Model\ReferenceWriteToken;

$writeToken = ReferenceWriteToken::generate();
$category = $client->categories()->create(
    name: 'Кафе',
    parentId: null, // null означает корневую категорию
    hidden: false,
    sort: 10,
    description: 'Еда вне дома',
    writeToken: $writeToken,
);

$category = $client->categories()->update($category->id, [
    'name' => 'Кафе и рестораны',
    'is_hidden' => false,
]);

$account = $client->places()->createAccount(
    name: 'Наличные для поездки',
    hidden: true,
);
$account = $client->places()->update($account->id, [
    'name' => 'Основная карта',
    'description' => 'Повседневные расходы',
]);

createAccount() намеренно создаёт только обычный type=4 счёт. SOAP не даёт безопасного контракта создания папок, кредитных карт или системных долговых счетов. Обновление также отклоняет такие объекты, потому что legacy setPlaceList может потерять их server-managed состояние.

places()->delete() ещё строже: он удаляет только обычный пустой счёт и перед deleteObject() проверяет весь журнал, включая плановые операции. Папки, долговые, credit-card, purse-owned и auto-hide счета отклоняются. Legacy-сервер не выполняет это удаление транзакционно, поэтому во время удаления нельзя параллельно добавлять операции в тот же счёт.

Источники доходов и теги поддерживают тот же CRUD:

$sources = $client->sources()->list(); // плоский список, отсортирован по sort
$sourceTree = $client->sources()->tree(includeHidden: false);
$sourceOptions = $client->sources()->options(includeHidden: false);

$source = $client->sources()->create('Возвраты');
$source = $client->sources()->update($source->id, ['name' => 'Возвраты и компенсации']);

$tag = $client->tags()->create('Командировка', hidden: true);
$tag = $client->tags()->update($tag->id, ['is_hidden' => false]);
$tagOptions = $client->tags()->options(includeHidden: false);

Список тегов может включать семейные теги других пользователей. tags()->update() и tags()->delete() намеренно работают только с тегами, чей userId совпадает с текущим пользователем: update через legacy setTagList иначе переписал бы владельца, а delete удалил бы чужой видимый семейный тег. Если владелец не пришёл в SOAP-ответе, SDK также отклоняет mutation.

При удалении тега Дребеденьги могут убрать первое вхождение [ИмяТега] из комментариев связанных операций, поэтому автоматическое удаление безопаснее использовать для новых или заведомо неиспользуемых тегов.

Валюты имеют отдельные методы для безопасного изменения курса и default:

$previousDefault = $client->currencies()->default();

$currency = $client->currencies()->create(
    name: 'Тестовая валюта', // name и code: максимум 16 символов
    course: '1.25',
    code: 'TST',
    hidden: true,
);

$currency = $client->currencies()->update($currency->id, [
    'course' => '1.30',
]);

// Менять default безопасно только если прежнюю валюту потом можно записать
// обратно через legacy setCurrencyList.
if (
    $previousDefault !== null
    && $previousDefault->ratio === 1
    && !$previousDefault->investing
) {
    $currency = $client->currencies()->setDefault($currency->id);
    $client->currencies()->setDefault($previousDefault->id);
}
$client->currencies()->delete($currency->id);

Legacy setCurrencyList принудительно записывает ratio=1 и is_investing=false. Поэтому SDK разрешает update/default только для обычных неинвестиционных валют с ratio=1 и отклоняет опасную запись crypto/investing валют до SOAP-вызова. Непосредственно перед типизированной mutation старый каталог валют инвалидируется, а после успешного ответа перечитывается. Если ответ потерян или не подтверждает целевой ID, следующий доступ снова обратится к серверу, а не вернёт потенциально устаревший снимок.

Перед типизированными update() и delete(), а также currencies()->setDefault(), SDK проверяет полный доступ. Update читает существующий объект и дополняет patch обязательными legacy-полями. Delete сначала убеждается, что ID принадлежит именно этому справочнику: серверный тип object общий для категорий, источников и счетов. Неизвестный ID возвращает false, не выполняя удаление. Для нестандартных будущих полей у каждого сервиса сохранён savePayloads() как raw escape hatch. Он намеренно обходит типизированные guards и read-back; после currencies()->savePayloads() вызови currencies()->refresh(), если общий каталог должен сразу увидеть изменения.

Удаление родительской категории сохраняет legacy cascade-семантику и может удалить всё поддерево. Источники используют тот же иерархический deleteObject(..., 'object'). SDK не добавляет leaf-only guard, поэтому перед удалением категории или источника проверь детей через list() / tree(), если каскад не задуман.

ReferenceWriteToken позволяет немедленно повторить создание того же справочного объекта с прежним client_id. Как и RecordWriteToken, это не долговечный idempotency key: повтор должен идти с совершенно тем же payload до следующей успешной записи справочника того же типа. При failover повторяй только на endpoint из AmbiguousMutationException.

Аккаунт и подписка:

$userId = $client->account()->userId();
$hasAccess = $client->account()->hasAccess();
$expireDate = $client->account()->expireDate();

Операции:

use Soz\Drebedengi\Model\RecordQuery;
use Soz\Drebedengi\Model\OperationType;

$records = $client->records()->list(
    RecordQuery::forDateRange(
        new DateTimeImmutable('2026-01-01'),
        new DateTimeImmutable('2026-01-31'),
    )
        ->operationType(OperationType::Expense)
        ->onlyPlaces(['11416426'])
        ->onlyCategories(['CATEGORY_ID'])
        ->withBalanceAfter()
);

foreach ($records as $record) {
    echo $record->balanceAfter?->toDecimalString();
}

Периоды и остальные фильтры detail-журнала:

$records = $client->records()->list(
    (new RecordQuery())
        ->thisMonth() // также today(), lastMonth(), thisQuarter(), thisYear(), lastYear(), allTime(), last20()
        ->relativeTo(new DateTimeImmutable('2026-07-14'))
        ->operationType(OperationType::Expense)
        ->includePlanned()
        ->includeDebts(false)
        ->forUser('USER_ID')
        ->exceptPlaces(['PLACE_ID'])
        ->onlyTags(['TAG_ID'])
        ->exceptCategories(['CATEGORY_ID']),
);

relativeTo() задаёт опорную дату для именованного периода и форматируется в timezone аккаунта. forAllUsers() возвращает query к семейной выборке. Для счетов, тегов и категорий доступны only...(), except...() и возврат к полной выборке через all...().

records()->list() без query и пустой new RecordQuery() одинаково возвращают последние 20 операций. Detail-журнал поддерживает только оригинальную валюту каждой операции: для пересчитанных финансовых сумм используйте ReportQuery::convertedToCurrency().

При includePlanned() в Record заполняются planned, plannedRepeatId, plannedPeriodId и plannedInitialDate. Плановые операции нельзя сочетать с withBalanceAfter(): SOAP не отдаёт готовый прогнозный остаток, а вычислять его из фактического остатка было бы неоднозначно.

Фильтры по категориям и тегам допустимы только для расходов или доходов, поэтому перед ними нужно явно выбрать OperationType::Expense или OperationType::Income. Несовместимое сочетание SDK отклоняет до SOAP-вызова.

По умолчанию RecordQuery использует r_currency=0, то есть оригинальную валюту операции. Обычное чтение использует безопасный detail report (is_report=true, r_how=1). В legacy SOAP режиме is_report=false сервер считает запрос первоначальной синхронизацией и сбрасывает служебные соответствия client_id / server_id, поэтому SDK не использует его для журнала.

withBalanceAfter() добавляет в каждый Record поле balanceAfter с остатком на счёте сразу после операции. Расчёт доступен только для оригинальной валюты и может выполнить дополнительный getRecordList, если исходный запрос содержит фильтры, а также один getBalance.

Агрегированные отчёты

ReportService отделён от журнала операций, потому что SOAP возвращает для агрегатов дерево категорий или источников, а не обычные Record:

use Soz\Drebedengi\Model\ReportQuery;

$query = ReportQuery::forDateRange(
    new DateTimeImmutable('2026-01-01'),
    new DateTimeImmutable('2026-01-31'),
)
    ->convertedToCurrency('CURRENCY_ID')
    ->onlyPlaces(['PLACE_ID'])
    ->averageDaily(); // также averageWeekly(), averageMonthly(), withoutAveraging()

$expenses = $client->reports()->expensesByCategory($query);
$income = $client->reports()->incomeBySource($query);

foreach ($expenses as $row) {
    echo $row->name . ': ' . $row->amount->toDecimalString();
}

Без query отчёт строится за текущий месяц в оригинальных валютах и без усреднения. ReportQuery поддерживает те же периоды, пользователей, планы, долги и only/except-фильтры, что и RecordQuery. ReportRow::amount имеет тип DecimalMoneyAmount: при валютном пересчёте сервер может вернуть дробное количество minor units, и SDK сохраняет его без округления.

Создание операций

Суммы в SDK представлены как integer minor units, то есть в мельчайших единицах конкретной валюты. Для обычных валют это исторически 2 знака после запятой, а для криптовалют точность может быть выше.

use Soz\Drebedengi\Model\ExpenseGroupItem;
use Soz\Drebedengi\Model\RecordWriteToken;

$currency = $client->currencies()->require('CURRENCY_ID');

$result = $client->records()->createExpense(
    placeId: 'PLACE_ID',
    categoryId: 'CATEGORY_ID',
    amount: $currency->amount('12.34'),
    currencyId: $currency->id,
    date: new DateTimeImmutable(),
    comment: 'Обед',
);

$recordId = $result->firstServerId();
$rawResponse = $result->raw;

$client->records()->createIncome(
    placeId: 'PLACE_ID',
    sourceId: 'SOURCE_ID',
    amount: $currency->amount('100.00'),
    currencyId: $currency->id,
    date: new DateTimeImmutable(),
    comment: 'Возврат',
);

$client->records()->createTransfer(
    fromPlaceId: 'FROM_PLACE_ID',
    toPlaceId: 'TO_PLACE_ID',
    amount: $currency->amount('50.00'),
    currencyId: $currency->id,
    date: new DateTimeImmutable(),
    comment: 'Перенос между счетами',
);

Группа расходов, например строки одного чека:

$client->records()->createExpenseGroup(
    placeId: 'PLACE_ID',
    items: [
        new ExpenseGroupItem('CATEGORY_ID_1', $currency->amount('12.34'), 'Кофе'),
        ['categoryId' => 'CATEGORY_ID_2', 'amount' => $currency->amount('56.78'), 'comment' => 'Продукты'],
    ],
    currencyId: $currency->id,
    date: new DateTimeImmutable(),
);

Для нескольких строк SDK отправляет один setRecordList с общим group_id, который связывает все позиции чека в одну группу.

Все методы records()->create*() возвращают WriteResult: в нём доступны serverIds, clientIds, firstServerId(), serverIdForClientId() и исходные строки raw. Объект также поддерживает count(), foreach и чтение $result[0]. Низкоуровневый savePayloads() сохранён для кода, которому нужен именно сырой SOAP-массив.

Если ответ на запись потерян, заранее созданный токен позволяет немедленно повторить тот же payload с тем же client_id:

$writeToken = RecordWriteToken::generate();
$operationDate = new DateTimeImmutable();

$result = $client->records()->createExpense(
    placeId: 'PLACE_ID',
    categoryId: 'CATEGORY_ID',
    amount: $currency->amount('12.34'),
    currencyId: $currency->id,
    date: $operationDate,
    comment: 'Обед',
    writeToken: $writeToken,
);

Токен фиксирует только идентификаторы: при повторе нужно передать совершенно те же аргументы, включая тот же $operationDate, суммы и комментарий. Legacy-дедупликация ограничена: следующий успешный setRecordList для того же API ID заменяет сохранённые соответствия. Повторяй запрос до другой записи и до sync()->initialRecords(). При failover нельзя повторять вызов на том же failover-клиенте: создай клиент с одним endpoint из AmbiguousMutationException::$endpoint либо сначала сверь результат чтением. Автоматического повтора mutations SDK не делает.

Currency::amount() — рекомендуемый способ создавать суммы: он сам применяет точность из ratio и связывает MoneyAmount с ID валюты. Валюту можно найти без ручного перебора:

$btc = $client->currencies()->requireByCode('BTC');

$amount = $btc->amount('0.00001234');

Прежний MoneyAmount::fromDecimalString() остаётся совместимым для валют с двумя знаками. Перед записью SDK загружает каталог валют и проверяет scale, а у суммы от Currency::amount() — ещё и совпадение currencyId. Поэтому потенциально неверная сумма отклоняется до SOAP-вызова. Отдельный аргумент currencyId в методах create*() пока сохранён ради обратной совместимости; передавай в него ID той же Currency, которая создала сумму. В следующей major-версии этот дубль можно будет убрать в пользу обязательной currency-bound суммы.

При чтении через records() и balance() суммы уже имеют правильный scale и связанный currencyId. withScale() и fromFloat() объявлены устаревшими. Для точного изменения scale с сохранением суммы используй rescale(); намеренная переинтерпретация тех же minor units называется reinterpretScale(). Каталог валют лениво кэшируется в пределах экземпляра DrebedengiClient; для принудительного обновления есть $client->currencies()->refresh().

createTransfer() и createExchange() сами создают парные записи и связывают их через client_move_id / client_change_id. Перевод на тот же самый счёт SDK отклоняет до SOAP-вызова.

Обновление и удаление

use Soz\Drebedengi\Model\RecordPatch;

$record = $client->records()->byIds(['RECORD_ID'])[0];
$result = $client->records()->update($record, new RecordPatch(
    amount: $currency->amount('25.00'),
    comment: 'Исправленный комментарий',
));

$client->records()->delete('RECORD_ID', $record->operationType);

RecordPatch поддерживает placeId, budgetObjectId, amount, operationDate, comment, currencyId и duty. Сумма трактуется как абсолютная, а тип расхода или дохода определяет её направление. Исходный Record остаётся неизменным. Перемещения и обмены состоят из двух связанных строк; одиночный update() отклоняет их до SOAP-вызова, чтобы не отправлять неполную пару. При смене currencyId одновременно передай amount, созданный через новую Currency; иначе currency-bound сумма исходной операции не пройдёт проверку согласованности.

DTO сохраняют исходный SOAP-массив в поле raw, чтобы можно было разбирать неизвестные legacy-поля без потери данных. Методы delete() принимают только положительные целочисленные server ID и проверяют их до SOAP-вызова.

Обработка ошибок

use Soz\Drebedengi\Exception\AmbiguousMutationException;
use Soz\Drebedengi\Exception\EndpointUnavailableException;
use Soz\Drebedengi\Exception\SoapFaultException;

try {
    $client->records()->createExpense(/* ... */);
} catch (AmbiguousMutationException $exception) {
    // Запись могла сохраниться. Не повторяй её с новым client_id.
} catch (EndpointUnavailableException $exception) {
    // retrySafe=true означает, что повтор не создаст дубль.
} catch (SoapFaultException $exception) {
    // Сервер ответил business/API fault.
}

У транспортных исключений доступны свойства method, endpoint, retrySafe и faultCode. AmbiguousMutationException наследует EndpointUnavailableException, поэтому ставь более узкий catch первым.

Синхронизация по revision

$current = $client->sync()->currentRevision();
$changes = $client->sync()->changesSince($lastSavedRevision);

Потребитель SDK должен сохранять последнюю успешно обработанную revision сам. Для cron-синхронизаций важно сохранять progress инкрементально после каждой обработанной revision, а не только в конце пачки.

Для настоящей первоначальной синхронизации можно получить полный набор записей в legacy sync/export-формате:

$initialRecords = $client->sync()->initialRecords();

Этот вызов намеренно использует is_report=false. Сервер Дребеденег очищает при нём служебную таблицу дедупликации client_id / server_id для текущего API ID. Не используй initialRecords() для обычного чтения журнала или проверки результата записи. Перед этим state-changing запросом SDK загружает каталог валют; суммы первоначальной синхронизации получают тот же правильный scale и currencyId, что и обычный журнал.

Прямой доступ к SOAP

Не все методы WSDL покрыты доменным API SDK. Для редких методов есть прямой вызов с автоматическим добавлением credentials:

$result = $client->raw()->call('getAccessStatus');
$accums = $client->raw()->call('getAccumList', [[]]);

Live-тесты

Скопируй .env.example в .env и заполни тестовый аккаунт:

DREB_TEST_BASE_URI=https://www.drebedengi.ru
DREB_TEST_API_ID=
DREB_TEST_LOGIN=
DREB_TEST_PASSWORD=
DREB_TEST_TIMEZONE=UTC
DREB_RUN_LIVE_READ_TESTS=1
DREB_RUN_LIVE_WRITE_TESTS=0
DREB_BROWSER_CHECKS=0

Read-only проверка настоящего API:

composer test:integration:read

Записывающие тесты отделены и требуют явного флага DREB_RUN_LIVE_WRITE_TESTS=1:

composer test:integration:write

Для обратной совместимости старый DREB_RUN_LIVE_TESTS=1 пока включает только read-only тесты и никогда не разрешает запись. Записывающие тесты создают только объекты с уникальными тестовыми именами или комментариями, покрывают операции и CRUD справочников, восстанавливают исходную default-валюту и удаляют fixtures двумя проходами. После каждого теста выполняется повторное чтение всех типов и проверка нулевого остатка. deleteAll в автоматических тестах не используется.

Если аккаунт отвечает No payment на setRecordList, тест записи будет пропущен. Для полной проверки создания/удаления операций нужен тестовый аккаунт с активным доступом к API-записи.

Текущие ограничения

  • Основной SDK работает только с SOAP API.
  • Credential-shaped методы покупок и чеков можно вызвать через raw(), но они пока не типизированы. Регистрация пользователя и некоторые внутренние методы сервера имеют другую сигнатуру credentials и намеренно не проксируются этим клиентом. Статус подписки уже доступен через account().