Skip to content

Commit

Permalink
Merge pull request #27 from PostHog/stripe-use-paid-timestamp
Browse files Browse the repository at this point in the history
feat: Support using invoice `paid_at` state transition timestamp for "Invoice Paid" event
  • Loading branch information
tkaemming authored Mar 18, 2024
2 parents f111114 + 233a86f commit 26fde86
Show file tree
Hide file tree
Showing 4 changed files with 65 additions and 14 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
# Install Node.js
- uses: actions/setup-node@v1
with:
node-version: 18
node-version: 18.12.1
cache: "yarn"

# Install dependencies
Expand Down
11 changes: 8 additions & 3 deletions index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getMeta, resetMeta } from '@posthog/plugin-scaffold/test/utils.js'
import { setupPlugin, jobs, runEveryMinute } from './index'
import { setupPlugin, jobs, runEveryMinute, INVOICE_EVENT_TIMESTAMP_TYPES } from './index'
import 'jest'

global.fetch = jest.fn(async (url) => ({
Expand Down Expand Up @@ -43,6 +43,7 @@ beforeEach(() => {
posthog.api.get.mockClear()
global.groupType = undefined
global.groupTypeIndex = undefined
global.getInvoiceTimestamp = INVOICE_EVENT_TIMESTAMP_TYPES['Invoice Period End Date']

mockStorage = new Map()
storage = {
Expand Down Expand Up @@ -120,7 +121,11 @@ test('setupPlugin groupType and groupTypeIndex need to be set', async () => {
await setupPlugin({ ...meta, config: { groupTypeIndex: 0, groupType: 'test' } })
})

test('runEveryMinute', async () => {
test.each([
['Invoice Period End Date', '2022-07-27T16:00:09.000Z'],
['Invoice Payment Date','2022-07-27T17:28:20.000Z'],
])('runEveryMinute', async (invoiceEventTimestampType, invoiceTimestamp) => {
global.getInvoiceTimestamp = INVOICE_EVENT_TIMESTAMP_TYPES[invoiceEventTimestampType]
expect(fetch).toHaveBeenCalledTimes(0)
expect(posthog.capture).toHaveBeenCalledTimes(0)

Expand Down Expand Up @@ -157,7 +162,7 @@ test('runEveryMinute', async () => {

expect(posthog.capture).toHaveBeenNthCalledWith(3, 'Stripe Invoice Paid', {
distinct_id: 'test_distinct_id',
timestamp: '2022-07-27T16:00:09.000Z',
timestamp: invoiceTimestamp,
stripe_customer_id: 'cus_stripeid1',
stripe_amount_paid: 2000,
stripe_amount_due: 2000,
Expand Down
59 changes: 49 additions & 10 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import { PluginEvent, Plugin, RetryError, CacheExtension, Meta, StorageExtension } from '@posthog/plugin-scaffold'

// This interface is a subset of the API response type as documented here:
// https://docs.stripe.com/api/invoices/object
interface StoredInvoice {
invoice_id: string
amount_paid: number
period_end: number
// older invoices may have been written without state transition data
status_transitions?: {
paid_at: number
}
}

// Keep this in sync with the `invoiceEventTimestamp` choices in config.json!
export const INVOICE_EVENT_TIMESTAMP_TYPES: Record<string, (invoice: StoredInvoice) => Date | undefined> = {
'Invoice Period End Date': (invoice) => new Date(invoice.period_end * 1000),
'Invoice Payment Date': (invoice) => {
// older invoices may have been written without state transition data
const paid_at = invoice.status_transitions?.paid_at
if (paid_at !== undefined) {
return new Date(paid_at * 1000)
} else {
return undefined
}
}
}

const DEFAULT_INVOICE_EVENT_TIMESTAMP_TYPE = 'Invoice Period End Date'

export async function setupPlugin({ config, global, storage }) {
if ((config.groupType || config.groupTypeIndex > -1) && !(config.groupType && config.groupTypeIndex > -1)) {
throw new Error('Both groupType and groupTypeIndex must be set.')
Expand All @@ -16,6 +44,9 @@ export async function setupPlugin({ config, global, storage }) {
}
}

global.getInvoiceTimestamp =
INVOICE_EVENT_TIMESTAMP_TYPES[config.invoiceTimestampType ?? DEFAULT_INVOICE_EVENT_TIMESTAMP_TYPE]

const authResponse = await fetchWithRetry('https://api.stripe.com/v1/customers?limit=1', global.defaultHeaders)

if (!statusOk(authResponse)) {
Expand Down Expand Up @@ -66,28 +97,33 @@ async function sendGroupEvent(invoice, customer, due_last_month, due_total, paid
})
}

function last_month(invoices, key) {
function last_month(global, invoices: StoredInvoice[], key) {
const today = new Date()
const firstDayThisMonth = Math.floor(new Date(today.getFullYear(), today.getMonth(), 1) / 1000)
const firstDayNextMonth = Math.floor(new Date(today.getFullYear(), today.getMonth() + 1, 1) / 1000)
const firstDayThisMonth = new Date(today.getFullYear(), today.getMonth(), 1)
const firstDayNextMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1)
return invoices
.filter(({ period_end }) => {
return period_end > firstDayThisMonth && period_end < firstDayNextMonth
.filter((invoice) => {
const timestamp = global.getInvoiceTimestamp(invoice)
return (
timestamp !== undefined // older invoices may not have all timestamp data
&& timestamp > firstDayThisMonth
&& timestamp < firstDayNextMonth
)
})
.map((invoice) => invoice[key])
.reduce((prev, cur) => prev + cur, 0)
}

async function sendInvoiceEvent(invoice, customer, global, storage, groupAddition) {
const paid_last_month = last_month(customer.invoices, 'amount_paid')
const paid_last_month = last_month(global, customer.invoices, 'amount_paid')
const paid_total = customer.invoices.reduce((prev, cur) => prev.amount_paid + cur.amount_paid, { amount_paid: 0 })

const due_last_month = last_month(customer.invoices, 'amount_paid')
const due_last_month = last_month(global, customer.invoices, 'amount_paid')
const due_total = customer.invoices.reduce((prev, cur) => prev.amount_paid + cur.amount_paid, { amount_paid: 0 })

posthog.capture('Stripe Invoice Paid', {
distinct_id: customer?.distinct_id,
timestamp: toISOString(invoice.period_end),
timestamp: global.getInvoiceTimestamp(invoice).toISOString(),
stripe_customer_id: invoice.customer.id,
stripe_amount_paid: invoice.amount_paid / 100,
stripe_invoice_id: invoice.id,
Expand Down Expand Up @@ -200,8 +236,11 @@ async function getOrSaveCustomer(invoice, customer, storage, global) {
fromStorage.invoices.push({
invoice_id: invoice.id,
amount_paid: invoice.amount_paid / 100,
period_end: invoice.period_end
})
period_end: invoice.period_end,
status_transitions: {
paid_at: invoice.status_transitions.paid_at
}
} as StoredInvoice)

await storage.set(`customer_${customer.id}`, fromStorage)
return fromStorage
Expand Down
7 changes: 7 additions & 0 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@
"choices": ["Yes", "No"],
"default": "No",
"hint": "If there is a user in Stripe that we can't find in PostHog, should we still send events?"
},
{
"key": "invoiceEventTimestamp",
"name": "Timestamp to use for Invoice Paid events",
"type": "choice",
"choices": ["Invoice Period End Date", "Invoice Payment Date"],
"default": "Invoice Period End Date"
}
]
}

0 comments on commit 26fde86

Please sign in to comment.