Zero-touch SaaS Bookkeeping with Stripe and Moneybird

Thijs Thijs Cadier on

In today’s post, we will show you our full process for booking Stripe payments for our SaaS business in our accounting software.

I often get asked how we do the bookkeeping side of our business by other founders from our neck of the woods. We are based in the EU, so we have to deal with more stringent requirements for keeping our books than companies in other parts of the world.

To fully comply, we need to connect the payment for our invoices to an actual bank transaction. Preferably on an individual level. This is, or could be, as complex as it sounds. Doing that puzzle manually for every payment is very time-consuming. But since we have a sizable aversion to manual work we automated it all πŸ˜ƒ.

We’ve blogged about how we handle VAT in the past. But that’s just one part of the puzzle. To satisfy your accountant, and eventually the tax authorities, you need to go a lot further.

This post is for people running or starting a SaaS business dealing with the same requirements. If you’re not in that group, it’s fine to stop reading.

We use an amazing bookkeeping product called Moneybird. The same accounting principles described here should work in other bookkeeping products too, though.

I will now cover all steps that lead to a completed booking of an invoice for a subscription.

Disclaimer: I am not an accountant and am just sharing how we approach this. Make sure to verify this approach with your own accountant.

An Invoice is Created

Whenever Stripe creates a draft invoice for one of our customer’s subscriptions, it fires an invoice.created event to our webhook. At that point, we create a draft invoice in Moneybird:

1
2
3
4
5
6
7
def create_invoice(account, stripe_invoice)
  RestClient.post(
    api_url('sales_invoices.json'),
    invoice_hash(account, stripe_invoice).to_json,
    api_headers
  )
end

Our blog post about VAT covers some more detail on that aspect.

Tip: Use periods in Moneybird when creating invoices. This way, revenue will be evened out over the months automatically. Some complex and expensive work your accountant needs to do every year is not necessary anymore then.

Invoice Payment Succeeds or Fails

A couple of days later, Stripe tries to collect payment for the invoice. This can succeed or fail. In both cases, we set the invoice state to sent. If the payment succeeds, we manually register it.

1
2
3
4
5
6
7
def invoice_payment_succeeded
  invoice = event.data.object
  @account = get_account(invoice.customer)

  Moneybird.register_payment(@account, invoice)
  Moneybird.send_invoice(@account, invoice)
end

Moneybird then changes the state of the invoice, but it does not consider it truly paid until the invoice is connected to a real bank transaction. So we are half-way done now.

The Payout Containing an Invoice is Created

Another number of days later, Stripe creates a payout that includes the payment for this invoice. It fires a payout.created event.

We’re now getting to the magic that makes our approach tick. We consider our Stripe balance to be the equivalent of a bank account. We simply recreate all payouts as financial bank statements on the bookkeeping side:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def payout_created
  payout = Stripe::Payout.retrieve(@payout_id)
  balance = full_stripe_balance # Fetches entire balance for this payout

  financial_mutations_attributes = {}
  balance.each_with_index do |line, i|
    financial_mutations_attributes[i + 1] = {
      "date" => Time.zone.at(line.created).strftime("%d-%m-%Y"),
      "message" => line.id,
      "amount" => line.net
    }
  end
  financial_statement = {
    "financial_statement" => {
      "financial_account_id" => 00000,
      "reference" => payout_id,
      "official_date" => Time.zone.at(payout.created).strftime("%d-%m-%Y"),
      "official_balance" => "0",
      "financial_mutations_attributes" => financial_mutations_attributes
    }
  }

  Moneybird.create_financial_statement(financial_statement)
end

We end up with a financial statement a line for every payment. The amount for the payment does not include Stripe’s fee. We will see why that’s important in the next step.

The Payout Containing the Invoice is Transferred to Our Bank Account

If all goes well, the payout is transferred to our bank account a little while later. Stripe fires a payout.paid event. The following bookings tie everything together.

We loop through the Moneybird financial statement and fetch the right one from the Stripe payout. Remember that there is a difference between the transaction’s amount and the invoice’s amount: The fees have already been deducted.

Let’s use an example here:

We have an invoice with a total amount of EUR 50. The Stripe fee was EUR 1. The invoice is in our local currency (currency conversion is another topic that I’m leaving out of scope for this post).

We then find the Moneybird invoice for that line:

1
mb_invoice = Moneybird.find_invoice(account.new_moneybird_contact_id, invoice)

Next up, we link up the booking to the invoice. We use the total amount for the invoice, EUR 50, in this case.

1
2
3
4
5
6
7
Moneybird.link_booking(
  mutation["id"],
  "booking_type" => "SalesInvoice",
  "booking_id" => mb_invoice["id"],
  "price" => invoice.amount_due,
  "price_base" => line.amount
)

The invoice is now considered fully paid by Moneybird: The full amount was booked against a financial transaction.

This leaves the financial transaction in an unresolved state though. Its amount was EUR 49, we booked 50 against it. So its internal balance is now at EUR -1.

We resolve this with our final booking step. We have a normal cost category for all Stripe fees. We book the remaining negative amount on it:

1
2
3
4
5
6
Moneybird.link_booking(
  mutation["id"],
  "booking_type" => "LedgerAccount",
  "booking_id" => 9999,
  "price_base" => fee.amount
)

The payout also includes a line with the total amount that’s transferred. We book both this transaction, and the equivalent transaction on our bank account against crossposts. This evens everything out to zero.

There you have it:

Yearly Report Time

At the beginning of the next year, it’s time for our accountant to start work on our books. They do most of their work within Moneybird and make sure any remaining inconsistencies are corrected.

Moneybird has a function that can create a standard audit file export. Our accountant uses that export to load all data into the software they use to do their audits and generate a yearly report.

Done

That’s it: Zero-touch SAAS bookkeeping while complying fully with the tax authority’s requirements. Let me know if I can help with some more code samples!

10 latest articles

Go back
Ruby magic icon

Subscribe to

Ruby Magic

Magicians never share their secrets. But we do. Sign up for our Ruby Magic email series and receive deep insights about garbage collection, memory allocation, concurrency and much more.

We'd like to set cookies, read why.