diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f67b0f5..d6fbc87 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -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 diff --git a/index.test.ts b/index.test.ts index caab518..f0fe045 100644 --- a/index.test.ts +++ b/index.test.ts @@ -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) => ({ @@ -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 = { @@ -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) @@ -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, diff --git a/index.ts b/index.ts index 03b0604..537e3cc 100644 --- a/index.ts +++ b/index.ts @@ -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 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.') @@ -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)) { @@ -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, @@ -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 diff --git a/plugin.json b/plugin.json index 23d5627..d8a7704 100644 --- a/plugin.json +++ b/plugin.json @@ -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" } ] }