sozidatel / drdengi-php-sdk
PHP SDK for the Drebedengi SOAP API.
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^11.5
- vlucas/phpdotenv: ^5.6
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().