From d3c13e43ec2701ce43f31868e49ea2c47931ed0c Mon Sep 17 00:00:00 2001 From: Ted Kaemming <65315+tkaemming@users.noreply.github.com> Date: Wed, 13 Mar 2024 18:11:52 -0700 Subject: [PATCH 1/6] Add new configuration parameter for selecting the timestamp to use for Invoice Paid events. --- plugin.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugin.json b/plugin.json index 23d5627..e4dcc8f 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": "invoiceTimestampType", + "name": "Timestamp to use for Invoice Paid events", + "type": "choice", + "choices": ["Invoice Period End Date", "Invoice Payment Date"], + "default": "Invoice Period End Date" } ] } From 9da64854d147b77f2c981b1cc2ba93a3f4881921 Mon Sep 17 00:00:00 2001 From: Ted Kaemming <65315+tkaemming@users.noreply.github.com> Date: Wed, 13 Mar 2024 18:48:38 -0700 Subject: [PATCH 2/6] Support switching timestamp based on configuration parameter. --- index.ts | 51 +++++++++++++++++++++++++++++++++++++++++++-------- plugin.json | 2 +- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/index.ts b/index.ts index 03b0604..00529dc 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,33 @@ import { PluginEvent, Plugin, RetryError, CacheExtension, Meta, StorageExtension } from '@posthog/plugin-scaffold' +// TODO: This should probably be split into read/write types to ensure all new +// values are written correctly. +interface StoredInvoice { + invoice_id: string + amount_paid: number + period_end: number + status_transitions?: { + paid_at: number + } +} + +// Keep this in sync with the `invoiceEventTimestamp` choices in config.json! +const INVOICE_EVENT_TIMESTAMP_TYPES: Record Date | undefined> = { + 'Invoice Period End Date': (invoice) => new Date(invoice.period_end * 1000), + 'Invoice Payment Date': (invoice) => { + // XXX: `status_transitions` isn't available on older events, but should + // exist on new events moving forward. + 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,29 @@ 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) return invoices - .filter(({ period_end }) => { - return period_end > firstDayThisMonth && period_end < firstDayNextMonth + .filter((invoice) => { + const timestamp = global.getInvoiceTimestamp(invoice) + return timestamp !== undefined && 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 +232,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 e4dcc8f..d8a7704 100644 --- a/plugin.json +++ b/plugin.json @@ -36,7 +36,7 @@ "hint": "If there is a user in Stripe that we can't find in PostHog, should we still send events?" }, { - "key": "invoiceTimestampType", + "key": "invoiceEventTimestamp", "name": "Timestamp to use for Invoice Paid events", "type": "choice", "choices": ["Invoice Period End Date", "Invoice Payment Date"], From f72f5e717e171f8c0f5375f63c89fc25ae2a0a09 Mon Sep 17 00:00:00 2001 From: Ted Kaemming <65315+tkaemming@users.noreply.github.com> Date: Wed, 13 Mar 2024 20:39:03 -0700 Subject: [PATCH 3/6] Add comments about not having all older invoices. --- index.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/index.ts b/index.ts index 00529dc..4dee1ba 100644 --- a/index.ts +++ b/index.ts @@ -1,11 +1,12 @@ import { PluginEvent, Plugin, RetryError, CacheExtension, Meta, StorageExtension } from '@posthog/plugin-scaffold' -// TODO: This should probably be split into read/write types to ensure all new -// values are written correctly. +// 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 } @@ -15,8 +16,7 @@ interface StoredInvoice { const INVOICE_EVENT_TIMESTAMP_TYPES: Record Date | undefined> = { 'Invoice Period End Date': (invoice) => new Date(invoice.period_end * 1000), 'Invoice Payment Date': (invoice) => { - // XXX: `status_transitions` isn't available on older events, but should - // exist on new events moving forward. + // 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) @@ -104,7 +104,11 @@ function last_month(global, invoices: StoredInvoice[], key) { return invoices .filter((invoice) => { const timestamp = global.getInvoiceTimestamp(invoice) - return timestamp !== undefined && timestamp > firstDayThisMonth && timestamp < firstDayNextMonth + 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) From b9f0ee2ce7961bc16d82767af1b937db456887eb Mon Sep 17 00:00:00 2001 From: Ted Kaemming <65315+tkaemming@users.noreply.github.com> Date: Wed, 13 Mar 2024 21:33:42 -0700 Subject: [PATCH 4/6] try to fix build --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 7ddd52a92d5fc52d38d99bfff2e80c23f55fadd5 Mon Sep 17 00:00:00 2001 From: Ted Kaemming <65315+tkaemming@users.noreply.github.com> Date: Thu, 14 Mar 2024 14:04:47 -0700 Subject: [PATCH 5/6] Fix types in filter comparison, expose INVOICE_EVENT_TIMESTAMP_TYPES for test (unfortunately) --- index.test.ts | 3 ++- index.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/index.test.ts b/index.test.ts index caab518..98df9f6 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 = { diff --git a/index.ts b/index.ts index 4dee1ba..537e3cc 100644 --- a/index.ts +++ b/index.ts @@ -13,7 +13,7 @@ interface StoredInvoice { } // Keep this in sync with the `invoiceEventTimestamp` choices in config.json! -const INVOICE_EVENT_TIMESTAMP_TYPES: Record Date | undefined> = { +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 @@ -99,8 +99,8 @@ async function sendGroupEvent(invoice, customer, due_last_month, due_total, paid 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((invoice) => { const timestamp = global.getInvoiceTimestamp(invoice) From 233a86f5f05f85d61783db78a0f83714521e3136 Mon Sep 17 00:00:00 2001 From: Ted Kaemming <65315+tkaemming@users.noreply.github.com> Date: Thu, 14 Mar 2024 16:48:28 -0700 Subject: [PATCH 6/6] Might as well add a test while we're here --- index.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/index.test.ts b/index.test.ts index 98df9f6..f0fe045 100644 --- a/index.test.ts +++ b/index.test.ts @@ -121,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) @@ -158,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,