<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Shane</title>
    <description>The latest articles on DEV Community by Shane (@shane_loomb).</description>
    <link>https://dev.to/shane_loomb</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4214747%2Fa02c2720-f9ed-42c8-93e0-2ca8002764a4.png</url>
      <title>DEV Community: Shane</title>
      <link>https://dev.to/shane_loomb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shane_loomb"/>
    <language>en</language>
    <item>
      <title>Stripe invoices aren't revenue: how to turn them into ASC 606 schedules with a webhook</title>
      <dc:creator>Shane</dc:creator>
      <pubDate>Sat, 31 Oct 2026 21:46:45 +0000</pubDate>
      <link>https://dev.to/shane_loomb/stripe-invoices-arent-revenue-how-to-turn-them-into-asc-606-schedules-with-a-webhook-lcb</link>
      <guid>https://dev.to/shane_loomb/stripe-invoices-arent-revenue-how-to-turn-them-into-asc-606-schedules-with-a-webhook-lcb</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I work on &lt;a href="https://defrr.com" rel="noopener noreferrer"&gt;defrr&lt;/a&gt;, the revenue schedule API used in this post.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Stripe is excellent at billing: subscriptions, invoices, payments. They have Revenue Recognition inside their dashboard, but it operates as a closed system with percentage-based volume fees.&lt;/p&gt;

&lt;p&gt;This post shows a small way to get that schedule from code instead: take the service period and amount off a Stripe invoice line and ask &lt;a href="https://defrr.com" rel="noopener noreferrer"&gt;defrr&lt;/a&gt; for the month-by-month schedule. First as a pure calculation, then, if you want it, recorded in defrr as a contract for your finance team to approve. I built it and ran it against Stripe's test mode, and I'll show you what I hit along the way.&lt;/p&gt;

&lt;p&gt;This is an example integration, not an official Stripe feature. defrr works out the schedule from the terms you send it; it doesn't connect to Stripe itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Background:&lt;/strong&gt; Under ASC 606 and IFRS 15, a customer who pays $2,400 upfront for a one-year plan has given you $2,400 of cash and $0 of earned revenue on day one. The money sits in deferred revenue and moves to the P&amp;amp;L as the service is delivered.  &lt;/p&gt;


&lt;h3&gt;What the Calculation Does&lt;/h3&gt;


&lt;p&gt;You send a contract's dates and price. You get back, for every month, what is billed, what is recognised as revenue and what is still deferred.&lt;/p&gt;

&lt;p&gt;Here is how you execute a contract calculation using a standard cURL request:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;curl https://defrr.com/v1/credits/calculate \
  -H "Authorization: Bearer $DEFRR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "create_contract",
    "schedule": {
      "recognition": "exact",
      "endDateIncluded": false,
      "terms": [{ "startDate": "2026-10-30", "endDate": "2027-10-30", "price": 2400 }]
    }
  }'&lt;/code&gt;&lt;/pre&gt;

&lt;blockquote&gt;
  At Defrr, every schedule calculation is completely stateless and deterministic. Whether executed via direct HTTP requests or integrated into automated AI agent workflows via remote MCP, the result guarantees daily ratable accuracy without relying on probabilistic estimation.
&lt;/blockquote&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;&lt;code&gt;recognition: "exact"&lt;/code&gt;:&lt;/strong&gt; recognizes revenue strictly per day, meaning a 31-day month earns more than a 30-day month. (Using &lt;code&gt;"smooth"&lt;/code&gt; provides equal monthly splits instead.)&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;&lt;code&gt;endDateIncluded: false&lt;/code&gt;:&lt;/strong&gt; means the end date is the first day of the next period, so this term covers 30 October 2026 to 29 October 2027. That matches how Stripe describes a billing period: the period ends at the moment the next one begins.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the API response (trimmed to the first three of the thirteen months touched by the term):&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  "mode": "create_contract",
  "contractSummary": {
    "totalPrice": 2400.0,
    "totalBilled": 2400.0,
    "totalRevenue": 2400.0,
    "recognitionBasis": "exact",
    "balanced": true
  },
  "schedule": [
    { "period": "2026-10", "billing": 2400.0, "revenue": 13.15,  "endingDeferredBalance": 2386.85 },
    { "period": "2026-11", "billing": 0.0,    "revenue": 197.26, "endingDeferredBalance": 2189.59 },
    { "period": "2026-12", "billing": 0.0,    "revenue": 203.84, "endingDeferredBalance": 1985.75 }
  ]
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;October gets 2 of the 365 days ($2,400 × 2/365 = $13.15). November, with 30 days, gets $197.26, and December, with 31, gets $203.84. The last month absorbs the cent of rounding, so the schedule ends at exactly zero deferred. &lt;strong&gt;&lt;code&gt;balanced: true&lt;/code&gt;:&lt;/strong&gt; tells you the totals add up: everything billed and everything recognised equals the contract price. It is a consistency check on the arithmetic.&lt;/p&gt;


&lt;h3&gt;Wiring It to Stripe&lt;/h3&gt;


&lt;p&gt;A small webhook handler is enough. When Stripe finalises an invoice, take the subscription line's service period and amount and request the schedule.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;import express from 'express';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const DEFRR_URL = 'https://defrr.com/v1/credits/calculate';

// Take dates in your accounting timezone, not UTC (see "A timezone trap" below).
const ACCOUNTING_TZ = process.env.ACCOUNTING_TZ ?? 'UTC';
const toDate = (unixSeconds: number) =&amp;gt;
  new Date(unixSeconds * 1000).toLocaleDateString('en-CA', { timeZone: ACCOUNTING_TZ });   // en-CA gives YYYY-MM-DD

export const app = express();

// Stripe signs the raw body, so don't let a JSON parser touch it first.
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) =&amp;gt; {
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body, req.headers['stripe-signature'] as string, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return res.status(400).send('bad signature');
  }
  if (event.type !== 'invoice.finalized') return res.status(200).send('ignored');

  const invoice = event.data.object as Stripe.Invoice;
  for (const line of invoice.lines.data) {
    // Only whole-period charges here: skip proration lines and credits.
    if ((line as any).proration || line.amount &amp;lt;= 0) continue;

    const response = await fetch(DEFRR_URL, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.DEFRR_API_KEY}`,
        'Content-Type': 'application/json',
        // Stripe retries webhooks. Keyed on the line, a retry isn't charged twice.
        'Idempotency-Key': `stripe-${line.id}`,
      },
      body: JSON.stringify({
        mode: 'create_contract',
        schedule: {
          recognition: 'exact',
          endDateIncluded: false,                       // Stripe's period.end is when the next period starts
          terms: [{
            startDate: toDate(line.period.start),
            endDate: toDate(line.period.end),
            price: line.amount / 100,                   // the TOTAL for the term (quantity x unit price), assumes a two-decimal currency
            quantity: line.quantity ?? 1,               // optional: the seats or units that total covers
          }],
        },
      }),
    });

    const result: any = await response.json();
    if (!response.ok || result.contractSummary?.balanced !== true) {
      return res.status(502).send('could not produce a balanced schedule');
    }
    await saveScheduleToLedger(invoice.id!, line.id, result.schedule);   // your own storage or journal posting
  }
  return res.status(200).json({ ok: true });
});

async function saveScheduleToLedger(invoiceId: string, lineId: string, schedule: unknown) {
  console.log('SCHEDULE for', invoiceId, lineId, JSON.stringify(schedule, null, 2));   // replace with your own storage
}

app.listen(3000, () =&amp;gt; console.log('listening on 3000'));&lt;/code&gt;&lt;/pre&gt;

&lt;blockquote&gt;
  Key implementation detail: Always include the &lt;code&gt;Idempotency-Key&lt;/code&gt; header when calling Defrr from webhooks. Because Stripe retries webhook payloads during network blips, idempotency guarantees your credit balance isn't decremented twice for the same invoice line item.
&lt;/blockquote&gt;

&lt;p&gt;The &lt;code&gt;price&lt;/code&gt; is always the total for the term. For a plan billed per seat, that is Stripe's quantity times the unit price, which is what &lt;code&gt;line.amount&lt;/code&gt; already holds. The optional &lt;code&gt;quantity&lt;/code&gt; doesn't change the schedule. defrr echoes it back with the price per unit, so you can see it was read as 5 seats and not as 1.&lt;/p&gt;


&lt;p&gt;One note on Stripe versions: where the proration flag and the line's price live depend on your Stripe API version. I ran this on version 2023-10-16, where &lt;code&gt;line.proration&lt;/code&gt; and &lt;code&gt;line.period&lt;/code&gt; (in unix seconds) are as above. On a newer version, check the invoice line item reference and adjust.&lt;/p&gt;


&lt;h3&gt;Try It Locally in Ten Minutes&lt;/h3&gt;


&lt;p&gt;You can run this integration end-to-end in Stripe's test mode on your local machine without deploying any infrastructure:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;strong&gt;Get a Defrr API Key:&lt;/strong&gt; Create a developer account at &lt;a href="https://defrr.com/developers" rel="noopener noreferrer"&gt;defrr.com/developers&lt;/a&gt; to instantly receive 40 free test credits.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;Set Up a Scratch Project:&lt;/strong&gt; Create a new directory and install dependencies:
    &lt;pre&gt;&lt;code&gt;mkdir defrr-stripe-demo &amp;amp;&amp;amp; cd defrr-stripe-demo
npm init -y
npm install express stripe tsx typescript @types/express&lt;/code&gt;&lt;/pre&gt;
    Save the Express webhook handler code above as &lt;code&gt;server.ts&lt;/code&gt;.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;Set Environment Variables:&lt;/strong&gt; Export your API credentials in your terminal session:
    &lt;pre&gt;&lt;code&gt;export STRIPE_SECRET_KEY="sk_test_..."
export DEFRR_API_KEY="dfr_live_..."
export STRIPE_WEBHOOK_SECRET="whsec_..." # Set in step 4&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;Forward Stripe Webhook Events:&lt;/strong&gt; Install the Stripe CLI, log in, and forward incoming events to your local server:
    &lt;pre&gt;&lt;code&gt;stripe login
stripe listen --forward-to localhost:3000/webhooks/stripe&lt;/code&gt;&lt;/pre&gt;
    Copy the webhook signing secret (&lt;code&gt;whsec_…&lt;/code&gt;) printed in your terminal and export it as &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt;.
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;Start the Local Server:&lt;/strong&gt; Run your server using &lt;code&gt;tsx&lt;/code&gt;:
    &lt;pre&gt;&lt;code&gt;npx tsx watch server.ts&lt;/code&gt;&lt;/pre&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;strong&gt;Trigger an Invoice Event:&lt;/strong&gt; In the Stripe Dashboard (in test mode), create a yearly subscription using test card &lt;code&gt;4242 4242 4242 4242&lt;/code&gt;. Your running terminal will immediately output the calculated ASC 606 revenue schedule.
  &lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
  &lt;strong&gt;Quick Verification Test:&lt;/strong&gt; To confirm signature verification is active, execute &lt;code&gt;curl -X POST localhost:3000/webhooks/stripe -d '{}'&lt;/code&gt; in a separate terminal. The server should return a &lt;code&gt;400 Bad Signature&lt;/code&gt; response.
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A timezone trap I hit
&lt;/h2&gt;

&lt;p&gt;My first run started revenue a day early!&lt;/p&gt;

&lt;p&gt;I'm in New Zealand, where it was the morning of 30 October. Stripe sends timestamps as Unix epoch moments in time, and my first version turned them into dates in UTC, where it was still 29 October. So defrr was told the service started on the 29th:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Start date sent&lt;/th&gt;
&lt;th&gt;October revenue&lt;/th&gt;
&lt;th&gt;Last month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UTC (first run)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-10-29&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$19.73 (3 days)&lt;/td&gt;
&lt;td&gt;$184.08&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New Zealand time (fixed)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-10-30&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$13.15 (2 days)&lt;/td&gt;
&lt;td&gt;$190.66&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both schedules balance, which is the problem: nothing tells you the start date is wrong. The fix is to take the date in your accounting timezone, which is what &lt;code&gt;ACCOUNTING_TZ&lt;/code&gt; does in the code above (for example &lt;code&gt;export ACCOUNTING_TZ=Pacific/Auckland&lt;/code&gt;). Use whichever timezone your finance team closes the books in.&lt;/p&gt;


&lt;h3&gt;Optional: Record the Contract in Defrr (Not Just Calculate It)&lt;/h3&gt;


&lt;p&gt;The calculation above works from dates and a price only: it doesn't know who the customer is - it holds zero PII and stores no records. If you also want the contract recorded in a defrr workspace, so your finance team can see it, approve it and report on it, use defrr's save tool instead. That needs a key with the "Save contracts in Defrr" option ticked, and it goes through the MCP endpoint.&lt;/p&gt;

&lt;p&gt;Now the customer, the product and a reference matter, because they're what the finance team sees. Replace the &lt;code&gt;fetch&lt;/code&gt; block and what follows it in the handler with:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const price = (line as any).price;   // shape depends on your Stripe API version; on 2023-10-16 it has nickname and lookup_key

const response = await fetch('https://defrr.com/v1/mcp', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.DEFRR_API_KEY!, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    jsonrpc: '2.0', id: 1, method: 'tools/call',
    params: {
      name: 'defrr_save_contract',
      arguments: {
        customerName: invoice.customer_name ?? invoice.customer_email ?? 'Unknown customer',
        product: (price?.nickname ?? line.description ?? 'Subscription').slice(0, 120),
        productSku: price?.lookup_key ?? undefined,
        reference: invoice.number ?? undefined,
        source: `Stripe invoice ${invoice.id}`,
        idempotencyKey: `stripe-${line.id}`,           // a retried webhook saves one draft, not two
        currency: line.currency.toUpperCase(),         // defrr converts if it isn't your organization's currency
        schedule: {
          recognition: 'exact',
          endDateIncluded: false,
          terms: [{
            startDate: toDate(line.period.start),
            endDate: toDate(line.period.end),
            price: line.amount / 100,                  // the TOTAL for the term, assumes a two-decimal currency
            quantity: line.quantity ?? 1,              // how many seats or units that total covers
          }],
        },
      },
    },
  }),
});

const body: any = await response.json();
const result = body.result;
if (!response.ok || body.error || !result || result.isError) {
  console.error('defrr refused:', JSON.stringify(body.error ?? result?.structuredContent ?? body));
  return res.status(502).send('could not save the contract in defrr');
}
const saved = result.structuredContent;
console.log(saved.duplicate ? 'ALREADY SAVED' : 'SAVED DRAFT', saved.draft.draftId,
  'total', saved.contractSummary.totalPrice, 'balanced', saved.contractSummary.balanced);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The draft contract appears in the defrr workspace like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvcxn03oazhns99q00xvd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvcxn03oazhns99q00xvd.png" alt="Screenshot show Draft contract in Defrr" width="799" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Things to keep in mind:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
&lt;strong&gt;It saves a draft.&lt;/strong&gt; Nothing is live, and no report reads it until a person approves it in defrr. The tool can't approve its own drafts unless you give the key the separate approve option.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;Send the total price and the quantity separately.&lt;/strong&gt; Stripe’s &lt;code&gt;line.amount&lt;/code&gt; is already the full amount for the line: for 5 seats at $480 each it is $2,400. Send that full amount as &lt;code&gt;price&lt;/code&gt; and the number of seats as &lt;code&gt;quantity&lt;/code&gt;. defrr works out the price per seat itself, so the draft shows 5 × $480.00 instead of 1 × $2,400. The billing and revenue are the same either way, because they follow the total..&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;A foreign currency&lt;/strong&gt; is handled by sending currency, as above. If it isn't your organization's own currency, defrr converts it at its daily rate for the start date, or an exchangeRate you send, and refuses rather than guessing if it has neither.&lt;/li&gt;
  &lt;li&gt;
&lt;strong&gt;TypeScript Type Safety:&lt;/strong&gt; Depending on your installed &lt;code&gt;@types/stripe&lt;/code&gt; package version, strict typing may complain about accessing nested properties on &lt;code&gt;line.price&lt;/code&gt;. Reading line details through a light cast (e.g., &lt;code&gt;(line as any).price&lt;/code&gt;) ensures compatibility across Stripe API versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;What Is and Isn't Stored&lt;/h2&gt;

&lt;p&gt;The calculation endpoint works only from the request: it doesn't store the contract or the schedule, so there is nothing about your customers to keep on defrr's side. The save tool in the previous section is different on purpose: it records the customer, product and schedule in your defrr workspace as a draft. Use whichever matches what you need.&lt;/p&gt;

&lt;h2&gt;Excluded&lt;/h2&gt;

&lt;p&gt;This example covers a subscription charge for a fixed period. Real Stripe accounts also have mid-period upgrades that produce proration lines and credit notes, usage-based prices etc. The example skips proration lines and credits on purpose. defrr can also price usage (flat, volume, prepaid credits, fixed fee plus overage), process renewals and work out credits and cancellations - we'll cover them in a future post!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get Started:&lt;/strong&gt; give it a try with 40 free credits at &lt;a href="https://defrr.com/developers" rel="noopener noreferrer"&gt;defrr.com/developers&lt;/a&gt;, there are instructions there for the API or as a remote MCP server for Claude and Cursor.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>typescript</category>
      <category>saas</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
