Building a YNAB CLI in Laravel

Chris GmyrChris Gmyr
9 min read

I've been using You Need a Budget (YNAB) since 2019 and love it. I'm in the budget daily, entering transactions, tinkering, and ensuring my family is on track with our financial goals and current priorities. However, as the transactions and payees pile up over the years, the web UI has become slower and slower. It's not that bad, but when I only want to enter a simple transaction or check on the current amount for a category I don't always want to wait 20-30 seconds for the entire web UI to load.

I used to use a fantastic Chrome extension called Sprout for entering transactions, but unfortunately the developer took the extension off the Chrome store and stopped development. I wanted something that could be equally as fast and easy to use.

Over this blog series, we'll utilize the PHP Laravel framework and its built-in tools to build a robust CLI application and interface with YNAB's API.

The CLI functionality will not replace the excellent web UI but only introduce faster ways to alleviate everyday tasks to go about our day. It's a perfect fit since I'm usually in my terminal during the day.

Before we begin, if you aren't a YNAB user, please sign up. Using my referral link will give you a 34 day trial and if you decide to purchase a subscription, YNAB will give us both a free month!

Set up Laravel

To get started, install Laravel from the docs. I usually use the Laravel installer, but do whatever works best for you. At the time of writing, this will install a new Laravel 11 application.

laravel new ynab
cd ynab

Because we want an easy to use interface within the CLI, we'll also want to install Laravel Prompts.

composer require laravel/prompts

Next, we'll want to add config values for our YNAB API key and the default budget ID that we'll want to utilize. Right now, I'm only setting this up as a single budget, but in the future we could make this more configurable, or you can do this on your own in your own application.

Within .env and .env.example, add

YNAB_TOKEN=
YNAB_BUDGET_ID=

Within config/services.php, add

    'ynab' => [
        'token' => env('YNAB_TOKEN'),
        'budget_id' => env('YNAB_BUDGET_ID'),
    ],

To get a YNAB API key, go into your Account Settings, scroll toward the bottom and within the Developer Settings click the Developer Settings button. On the next page, under Personal Access Tokens, click the New Token button. This will reveal your API token that you can paste into your .env file.

The budget id is the UUID in the URL when viewing your budget in the YNAB web UI, so you can copy/paste if from there, or get it from the API (/budgets).

Create Transaction Command

Next, we'll want to set up our initial artisan command, so we'll run php artisan make:command CreateTransaction, which will create app/Console/Commands/CreateTransaction.php. Then, we'll stub out the initial command class.

class CreateTransaction extends Command
{
    protected $signature = 'ynab:transaction';
    protected $description = 'Creates a new transaction in YNAB';

    public function handle(): int
    {
        return self::SUCCESS;
    }

Before we get too far, let's set up a shared trait for the HTTP client to be shared across all commands, since we'll be creating more commands in the next blog posts. Create app/Console/Commands/Concerns/Ynab.php with the following code

<?php
declare(strict_types=1);

namespace App\Console\Commands\Concerns;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

trait Ynab
{
    protected function getClient(): PendingRequest
    {
        return Http::baseUrl('https://api.ynab.com/v1/')
            ->acceptJson()
            ->asJson()
            ->throw()
            ->withToken(config('services.ynab.token'));
    }
}

This helper method will allow us to remove the boilerplate of setting up the YNAB client and set up JSON handling, throw an exception if there's an API error, and use the token from the config file for authentication.

Then, we'll include that in the transaction command class

class CreateTransaction extends Command
{
+    use Concerns\Ynab;
+
    protected $signature = 'ynab:transaction';
    protected $description = 'Creates a new transaction in YNAB';

Similar to the Sprout UI, we'll want to build a CLI to handle:

  • Amount

  • Account

  • Payee

  • Category

  • Optional memo

  • Optional flag

  • Optional cleared/uncleared

For now, we'll skip date (we'll assume today's date), approval (default to true), split transactions, and other options found in the Sprout and YNAB UIs. For reference, here is the Sprout UI.

Sprout extension UI

Gathering Budget Data

Before we start building our CLI UI, we'll need to get initial budget data from YNAB. You can find the API docs here. For now, we'll want to gather

  • Accounts

  • Payees

  • Categories

So, we'll stub those out in our command

class CreateTransaction extends Command
{
    use Concerns\Ynab;

    protected $signature = 'ynab:transaction';
    protected $description = 'Creates a new transaction in YNAB';

    public function handle(): int
    {
+        $accounts = $this->getAccounts();
+        $payees = $this->getPayees();
+        $categories = $this->getCategories();
+
        return self::SUCCESS;
    }

Getting Budget Accounts

    public function getAccounts(): Collection
    {
        $response = $this->getClient()->get('budgets/' . config('services.ynab.budget_id') . '/accounts');

        return collect($response->json('data.accounts'))
            ->filter(fn($account) => $account['on_budget'] && !$account['closed'])
            ->mapWithKeys(fn($account) => [$account['id'] => $account['name']]);
    }

In this step, we're

  1. Getting the raw response from the /accounts endpoint from the API

  2. Grabbing the data from the response under data.accounts

  3. Filtering for only accounts "on budget" and not closed. On budget means that the money is used for specific budgeting purposes. Alternatively, there are "tracking" accounts that don't impact your budget positively or negatively.

  4. Creating a new collection of account IDs matching their name

Getting Budget Payees

    public function getPayees(): Collection
    {
        $response = $this->getClient()->get('budgets/' . config('services.ynab.budget_id') . '/payees');

        return collect($response->json('data.payees'))
            ->reject(fn($payee) => $payee['deleted'])
            ->mapWithKeys(fn($payee) => [$payee['id'] => $payee['name']]);
    }

In this step, we're

  1. Getting the raw response from the /payees endpoint from the API

  2. Grabbing the data from the response under data.payees

  3. Removing any deleted payees

  4. Creating a new collection of payee IDs matching their name

Getting Budget Categories

This step is a little more involved because the /categories endpoint has top-level Category Groups, then includes the categories data within each group. You can see the schema for CategoryGroupWithCategories here.

    public function getCategories(): Collection
    {
        $response = $this->getClient()->get('budgets/' . config('services.ynab.budget_id') . '/categories');

        return collect($response->json('data.category_groups'))
            ->reject(fn($categoryGroup) => $categoryGroup['hidden'] || $categoryGroup['deleted'])
            ->pluck('categories')
            ->flatten(1)
            ->reject(fn($category) => $category['hidden'] || $category['deleted'] || $category['category_group_name'] === 'Credit Card Payments' || $category['category_group_name'] === 'Internal Master Category')
            ->mapWithKeys(fn($category) => [$category['id'] => $category['category_group_name'].': '.$category['name']]);
    }

In this step, we're

  1. Getting the raw response from the /categories endpoint from the API

  2. Grabbing the data from the response under data.category_groups

  3. Removing hidden and deleted category groups

  4. Grabbing the categories array from each group

  5. Flattening the collection so all of the categories are in the same root of the collection

  6. Removing any categories that are hidden, deleted, or match specific YNAB-generate categories we don't need

  7. Creating a new collection of category IDs matching the category group name with the category name

Whew, we did it! Now, we'll move on to the Laravel Prompts UI.

Leveraging Laravel Prompts for the CLI UI

Prompts has numerous options for CLI inputs. These can either be one-off or chained together into a form. A form enables us to have multiple questions and inputs while collecting the responses to be used later. Since we'll have a number of questions to ask, we'll be using a form.

        $responses = form()
            ->text('Amount', required: true, name: 'amount')
            ->select('Account', options: $accounts, required: true, name: 'account')
            ->search(
                label: 'Payee',
                options: fn (string $value) => strlen($value) > 0
                    ? $payees->filter(fn ($payee) => Str::contains($payee, $value, true))->all()
                    : [],
                name: 'payee'
            )
            ->search(
                label: 'Category',
                options: fn (string $value) => strlen($value) > 0
                    ? $categories->filter(fn ($category) => Str::contains($category, $value, true))->all()
                    : [],
                name: 'category'
            )
            ->text('Memo (optional)', name: 'memo')
            ->select('Flag color (optional)', options: ['none', 'red', 'orange', 'yellow', 'green', 'blue', 'purple'], name: 'flag_color')
            ->select('Cleared', options: ['cleared', 'uncleared'], default: 'uncleared', name: 'cleared')
            ->submit();

There's a lot to unpack here, but it should be straight forward

  1. We'll ask for the amount of the transaction - either positive (income) or negative (expense)

  2. Select the account from a select prompt loaded with our accounts from the API

  3. A search box for the payee loaded from the API. As new characters are added, the search suggest auto-updates and allows you to arrow up/down to select the payee. The options function searches and returns the values the match the input given.

  4. Similar to the payees, the categories work the same way with a search suggest

  5. Memo is a simple text field and also optional

  6. Flag color is also optional using a select, like accounts. YNAB allows you to use their default flags, or change them in the UI. I only use these occasionally.

  7. Cleared or uncleared transaction using another select. If I already know the transaction has cleared the bank/card, then I'll change to cleared but otherwise most transactions are new and haven't hit the account yet, so we'll default to uncleared

  8. Finally, submit the responses

Creating the new transaction via the API

The Laravel Prompts form will return an array of $responses that will contain the key/value from what's provided in each input's name. We'll use the /transactions endpoint to combine our data and use some sensible defaults for other transaction-related data points.

        $response = $this->getClient()->post('budgets/' . config('services.ynab.budget_id') . '/transactions', [
            'transaction' => [
                'account_id' => $responses['account'],
                'payee_id' => $responses['payee'],
                'category_id' => $responses['category'],
                'amount' => $responses['amount'] * 1000,
                'memo' => $responses['memo'],
                'flag_color' => $responses['flag_color'] === 'none' ? null : $responses['flag_color'],
                'cleared' => $responses['cleared'],
                'date' => now()->toDateString(),
                'approved' => true,
            ],
        ]);
  1. The account will be the account's uuid from YNAB

  2. The payee will be the payee's uuid

  3. The category will be the category's uuid

  4. The amount (positive or negative) needs to be converted to what YNAB calls milliunits or 3 decimal places, so we'll multiply our amount by 1,000 (docs)

  5. Optional memo text

  6. Optional flag color, or converting none to null in case a color isn't specified

  7. Cleared value

  8. Current date - I usually add transactions as they happen, so we'll default to the current date. We might make this configurable in the future.

  9. Approved defaulted to true - YNAB will highlight any new transactions via their auto-import process which you can either approve or delete. However, for the CLI's purposes, I'm entering the transaction manually, I'm approving at the same time.

Handling API responses

At this point, I'm not too worried about responses and everything has been working smoothly so far. We'll expand on this in the future, but again, we'll keep things simple right now.

        if ($response->successful()) {
            $this->info('Expense created successfully');
        } else {
            $this->error('Failed to create expense - ' . $response->json('error.detail'));
        }

        return self::SUCCESS;

Wrapping Up

I hope you enjoyed this quick journey through setting up a fresh, new, Laravel application with Prompts and the YNAB API. As stated earlier, I'll be adding more blog posts to this series as I work through other command and refactorings along the way. I'm sure you can already see some areas that could use improvement. Further, the YNAB API offers some interesting options like Delta Requests, Rate Limiting, and better error handling that we can explore later as well.

Live Demo

demo CLI transaction

Check out the repo on GitHub

Listen to the podcast episode

Subscribe

Please subscribe to my newsletter, RSS feed, podcast, or the GH repo to follow along for more content!

Join the YNAB Family

Signing up using my referral link will give you a 34 day trial and if you decide to purchase a subscription, YNAB will give us both a free month!

0
Subscribe to my newsletter

Read articles from Chris Gmyr directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Chris Gmyr
Chris Gmyr

Husband, dad, & grilling aficionado. Loves Laravel & coffee. Staff Engineer @ Curology, and TrianglePHP Co-organizer