I use Harvest for client invoicing, and it's pretty great—simple interface, clean invoice templates, highly customizable, and they have excellent customer support. There was just one thing I needed that it didn't offer: automatic late fees. To work around that, I wrote a simple Laravel command and scheduled it to run once every day.
Here's a quick summary of what this command does and how it works:
Every day at 10:00pm, retrieve all open Invoices from Harvest
For Invoices due 3 days ago, create a new late fee Line Item
For Invoices due more than 3 days ago, increment that Line Item quantity
Let's dive into each piece of that puzzle, and take a look at the actual code.
Harvest API Setup
First things first, you'll need to establish a connection with Harvest's API to retrieve open Invoices. Hop over to the Developers tab on Harvest ID and generate an API key under Personal Access Tokens. Drop the account ID and token values into .env
properties (eg, HARVEST_ACCOUNT_ID
and HARVEST_TOKEN
respectively).
Then in your config/services
, add a new entry for Harvest:
1'harvest' => [2 'id' => env('HARVEST_ACCOUNT_ID'),3 'token' => env('HARVEST_TOKEN'),4],
Helper Methods
Our command will need to reuse a few bits of code multiple times during each scheduled run. Let's go ahead and break those out into dedicated helper methods.
Since we'll be hitting several different endpoints throughout the command—but using the same credentials for each request—we can return a "base" request from a dedicated method. For one-off external requests like this, I like to use Laravel's handy HTTP client:
1use Illuminate\Http\Client\PendingRequest;2use Illuminate\Support\Facades\Http;3 4protected function harvestRequest(): PendingRequest5{6 return Http::withHeaders([7 'Harvest-Account-Id' => config('services.harvest.id')8 ])->withToken(config('services.harvest.token'));9}
Next, for each Invoice we look at, we'll need to calculate the number of days that have passed since the due date. Here's a quick one-liner that will do just that, using Carbon's diffInDays
:
1use Carbon\Carbon;2 3protected function daysSinceDue($invoice)4{5 return (int) Carbon::make($invoice->due_date)->diffInDays();6}
Retrieve Invoices via API
Now that we've got the Harvest API all wired up, we'll configure a few constants and then grab all Invoices that are in an open
state, running those through our two fee-managing methods:
1const FEE_PERCENT = .05; // Default fee percentage 2const FEE_MINIMUM = 50; // Minimum fee in dollars 3const FEE_INTERVAL_DAYS = 30; // Number of days between each increment 4 5public function handle() 6{ 7 $data = $this->harvestRequest() 8 ->get('https://api.harvestapp.com/v2/invoices', [ 9 'state' => 'open',10 ])->body();11 12 $invoices = collect(json_decode($data)->invoices);13 14 $this->createInitialLateFeeFor($invoices);15 $this->updateExistingLateFeeFor($invoices);16}
Both the createInitialLateFeeFor
and updateExistingLateFeeFor
methods will handle their own conditional logic. Let's define those next.
Create Initial Late Fees
First, we'll pass our Invoice collection into the createInitialLateFeeFor
method. Code first, and some added context below.
1protected function createInitialLateFeeFor($invoices) 2{ 3 $invoicesDueTwoDaysAgo = $invoices->filter(function($invoice){ 4 return $this->daysSinceDue($invoice) === 3; 5 }); 6 7 $invoicesDueTwoDaysAgo->each(function($i){ 8 $amount = max($i->amount * static::FEE_PERCENT, static::FEE_MINIMUM); 9 10 $response = $this->harvestRequest()11 ->patch("https://api.harvestapp.com/v2/invoices/{$i->id}",[12 'line_items' => [13 [14 'description' => 'Automated fee, per invoice terms.',15 'kind' => 'Late Fee',16 'unit_price' => $amount,17 'quantity' => 1,18 ]19 ],20 ]);21 });22}
We start with a filter
to skip Invoices that weren't due exactly three days ago. Some notes:
I say "exactly", but be aware of timezone tomfoolery. If your application uses UTC, you might need to +/- this number to hit your target number of passed days.
This number is arbitrary, and the whole thing is optional. I just like to give clients a littttle extra wiggle room to take care of an invoice. Who among us hasn't been late, ya know?
We're left with a subset of qualified Invoices (ie, in an open
state and due X days ago). For each one, we calculate an $amount
using our config constants—multiplying the FEE_PERCENT
by the invoice amount, comparing against the FEE_MINIMUM
, and taking whichever is larger via the max()
function. This portion is just a reflection of our contract terms; you can adjust that math to reflect your own business logic.
From there, we just lean on our harvestRequest
helper to generate a new late fee Line Item for each qualified Invoice. Tweak the description
and kind
to taste...but be aware you might need to setup the latter in Harvest, via Invoices > Configure > Item Types if you're passing a custom type (ie, something other than Product or Service) like I did with Fee.
Increment Existing Late Fees
Second, we send the same Invoice collection into the updateExistingLateFeeFor
method. Mo' code, followed by mo' context on the other side.
1protected function updateExistingLateFeeFor($invoices) 2{ 3 $invoicesToEscalate = $invoices->filter(function($invoice){ 4 return $this->daysSinceDue($invoice) % static::FEE_INTERVAL_DAYS == 0; 5 }); 6 7 $invoicesToEscalate->each(function($i){ 8 $lateFeeLineItem = collect($i->line_items) 9 ->firstWhere(fn ($li) => str_contains($li->description, 'late fee'));10 if(! $lateFeeLineItem){ return; }11 $lateFeeLineItem->quantity++;12 13 $response = $this->harvestRequest()14 ->patch("https://api.harvestapp.com/v2/invoices/{$i->id}", [15 'line_items' => [ (array) $lateFeeLineItem ],16 ]);17 });18}
Again, we apply conditional logic in the method, using a filter
and modulo operator (%
) to get only the Invoices with a due date X days before today, where X is your FEE_INTERVAL_DAYS
config constant. So, for example, with a value of 30
, open invoices that were due exactly 30, 60, 90, 120, etc. days before today would qualify.
With each of these Invoices, we find the first Line Item that includes "late fee" in the description (or return early, if none are found) and increment that item's quantity
. Then we leverage our harvestRequest
helper one more time, to patch
the Line Items of that Invoice.
Schedule the Command
Almost there! We just need to define the schedule. Due to the date comparison checks, you'll want to run this command exactly once per day. I would recommend something like this in your routes/console
:
1use App\Console\Commands\ApplyClientLateFees;2use Illuminate\Support\Facades\Schedule;3 4Schedule::command(ApplyClientLateFees::class)5 ->timezone('America/Chicago')6 ->dailyAt('22:00');
If your production environment does not already have a cron entry setup, be sure to add one by following the Laravel docs or using Forge's handy Scheduler. Happy invoicing! ✌