diff --git a/README.md b/README.md index 1bbcd6c..cc0ce58 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ This server synchronizes your Stripe account to a Postgres database. It can be a - [x] `customer.subscription.resumed` 🟢 - [x] `customer.subscription.trial_will_end` 🟢 - [x] `customer.subscription.updated` 🟢 +- [x] `customer.tax_id.created` 🟢 +- [x] `customer.tax_id.deleted` 🟢 +- [x] `customer.tax_id.updated` 🟢 - [x] `customer.updated` 🟢 - [x] `invoice.created` 🟢 - [x] `invoice.deleted` 🟢 diff --git a/db/migrations/0025_tax_ids.sql b/db/migrations/0025_tax_ids.sql new file mode 100644 index 0000000..c0d063a --- /dev/null +++ b/db/migrations/0025_tax_ids.sql @@ -0,0 +1,14 @@ +create table if not exists + "stripe"."tax_ids" ( + "id" text primary key, + "object" text, + "country" text, + "customer" text, + "type" text, + "value" text, + "created" integer not null, + "livemode" boolean, + "owner" jsonb + ); + +create index stripe_tax_ids_customer_idx on "stripe"."tax_ids" using btree (customer); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 70541fd..c6daee1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "pg": "^8.11.3", "pg-node-migrations": "0.0.8", "pino": "^8.17.2", - "stripe": "^14.13.0", + "stripe": "^14.21.0", "yesql": "^7.0.0" }, "devDependencies": { @@ -6316,9 +6316,9 @@ } }, "node_modules/stripe": { - "version": "14.13.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.13.0.tgz", - "integrity": "sha512-uLOfWtBUL1amJCTpKCXWrHntFOSaO2PWb/2hsxV/OlXLr0bz5MyU8IW1pFlmZqpw6hBqAW5Fad7Ty7xRxDYrzA==", + "version": "14.21.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.21.0.tgz", + "integrity": "sha512-PFmpl35Myn6UDdVLTHcuppdbkPVvlQfkMHOmgGZh5QOdSUxVmvz090Z4obLg8ta1MNs1PNpzr9i7E39iAIv07A==", "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" @@ -11436,9 +11436,9 @@ "dev": true }, "stripe": { - "version": "14.13.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.13.0.tgz", - "integrity": "sha512-uLOfWtBUL1amJCTpKCXWrHntFOSaO2PWb/2hsxV/OlXLr0bz5MyU8IW1pFlmZqpw6hBqAW5Fad7Ty7xRxDYrzA==", + "version": "14.21.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.21.0.tgz", + "integrity": "sha512-PFmpl35Myn6UDdVLTHcuppdbkPVvlQfkMHOmgGZh5QOdSUxVmvz090Z4obLg8ta1MNs1PNpzr9i7E39iAIv07A==", "requires": { "@types/node": ">=8.1.0", "qs": "^6.11.0" diff --git a/package.json b/package.json index cc2cae9..a87313a 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "pg": "^8.11.3", "pg-node-migrations": "0.0.8", "pino": "^8.17.2", - "stripe": "^14.13.0", + "stripe": "^14.21.0", "yesql": "^7.0.0" }, "devDependencies": { diff --git a/src/lib/sync.ts b/src/lib/sync.ts index cebebce..d64dece 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -16,6 +16,7 @@ import { upsertPaymentIntents } from './payment_intents' import { upsertPlans } from './plans' import { upsertSubscriptionSchedules } from './subscription_schedules' import pLimit from 'p-limit' +import { upsertTaxIds } from './tax_ids' const config = getConfig() @@ -36,6 +37,7 @@ interface SyncBackfill { paymentMethods?: Sync disputes?: Sync charges?: Sync + taxIds?: Sync } export interface SyncBackfillParams { @@ -58,6 +60,7 @@ type SyncObject = | 'charge' | 'payment_intent' | 'plan' + | 'tax_id' export async function syncSingleEntity(stripeId: string) { if (stripeId.startsWith('cus_')) { @@ -84,6 +87,8 @@ export async function syncSingleEntity(stripeId: string) { return stripe.charges.retrieve(stripeId).then((it) => upsertCharges([it])) } else if (stripeId.startsWith('pi_')) { return stripe.paymentIntents.retrieve(stripeId).then((it) => upsertPaymentIntents([it])) + } else if (stripeId.startsWith('txi_')) { + return stripe.taxIds.retrieve(stripeId).then((it) => upsertTaxIds([it])) } } @@ -100,7 +105,8 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise { + console.log('Syncing tax_ids') + + const params: Stripe.TaxIdListParams = { limit: 100 } + + return fetchAndUpsert( + () => stripe.taxIds.list(params), + (items) => upsertTaxIds(items, syncParams?.backfillRelatedEntities) + ) +} + export async function syncPaymentMethods(syncParams?: SyncBackfillParams): Promise { // We can't filter by date here, it is also not possible to get payment methods without specifying a customer (you need Stripe Sigma for that -.-) // Thus, we need to loop through all customers diff --git a/src/lib/tax_ids.ts b/src/lib/tax_ids.ts new file mode 100644 index 0000000..3ec06f2 --- /dev/null +++ b/src/lib/tax_ids.ts @@ -0,0 +1,31 @@ +import Stripe from 'stripe' +import { getConfig } from '../utils/config' +import { constructUpsertSql } from '../utils/helpers' +import { backfillCustomers } from './customers' +import { getUniqueIds, upsertMany } from './database_utils' +import { taxIdSchema } from '../schemas/tax_id' +import { pg as sql } from 'yesql' +import { query } from '../utils/PostgresConnection' + +const config = getConfig() + +export const upsertTaxIds = async ( + taxIds: Stripe.TaxId[], + backfillRelatedEntities: boolean = true +): Promise => { + if (backfillRelatedEntities) { + await backfillCustomers(getUniqueIds(taxIds, 'customer')) + } + + return upsertMany(taxIds, () => constructUpsertSql(config.SCHEMA, 'tax_ids', taxIdSchema)) +} + +export const deleteTaxId = async (id: string): Promise => { + const prepared = sql(` + delete from "${config.SCHEMA}"."tax_ids" + where id = :id + returning id; + `)({ id }) + const { rows } = await query(prepared.text, prepared.values) + return rows.length > 0 +} diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 6b2d45b..b653164 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -14,6 +14,7 @@ import { upsertDisputes } from '../lib/disputes' import { deletePlan, upsertPlans } from '../lib/plans' import { upsertPaymentIntents } from '../lib/payment_intents' import { upsertSubscriptionSchedules } from '../lib/subscription_schedules' +import { deleteTaxId, upsertTaxIds } from '../lib/tax_ids' const config = getConfig() @@ -62,6 +63,17 @@ export default async function routes(fastify: FastifyInstance) { await upsertSubscriptions([subscription]) break } + case 'customer.tax_id.updated': + case 'customer.tax_id.created': { + const taxId = event.data.object as Stripe.TaxId + await upsertTaxIds([taxId]) + break + } + case 'customer.tax_id.deleted': { + const taxId = event.data.object as Stripe.TaxId + await deleteTaxId(taxId.id) + break + } case 'invoice.created': case 'invoice.deleted': case 'invoice.finalized': diff --git a/src/schemas/tax_id.ts b/src/schemas/tax_id.ts new file mode 100644 index 0000000..15efb72 --- /dev/null +++ b/src/schemas/tax_id.ts @@ -0,0 +1,18 @@ +import { JsonSchema } from '../types/types' + +export const taxIdSchema: JsonSchema = { + $id: 'taxIdSchema', + type: 'object', + properties: { + id: { type: 'string' }, + country: { type: 'string' }, + customer: { type: 'string' }, + type: { type: 'string' }, + value: { type: 'string' }, + object: { type: 'string' }, + created: { type: 'integer' }, + livemode: { type: 'boolean' }, + owner: { type: 'object' }, + }, + required: ['id'], +} as const diff --git a/test/stripe/customer_tax_id_created.json b/test/stripe/customer_tax_id_created.json new file mode 100644 index 0000000..14babbd --- /dev/null +++ b/test/stripe/customer_tax_id_created.json @@ -0,0 +1,30 @@ +{ + "id": "evt_3KtQThJDPojXS6LN0E06aNxq", + "object": "event", + "api_version": "2020-03-02", + "created": 1619701111, + "data": { + "object": { + "id": "txi_1NuMB12eZvKYlo2CMecoWkZd", + "object": "tax_id", + "country": "DE", + "created": 123456789, + "customer": null, + "livemode": false, + "type": "eu_vat", + "value": "DE123456789", + "verification": null, + "owner": { + "type": "self", + "customer": null + } + } + }, + "livemode": false, + "pending_webhooks": 4, + "request": { + "id": "req_QyDCzn33ls4m1t", + "idempotency_key": null + }, + "type": "customer.tax_id.created" +} diff --git a/test/stripe/customer_tax_id_deleted.json b/test/stripe/customer_tax_id_deleted.json new file mode 100644 index 0000000..088a795 --- /dev/null +++ b/test/stripe/customer_tax_id_deleted.json @@ -0,0 +1,30 @@ +{ + "id": "evt_3KtQThJDPojXS6LN0E06aNxq", + "object": "event", + "api_version": "2020-03-02", + "created": 1619701111, + "data": { + "object": { + "id": "txi_1NuMB12eZvKYlo2CMecoWkZd", + "object": "tax_id", + "country": "DE", + "created": 123456789, + "customer": null, + "livemode": false, + "type": "eu_vat", + "value": "DE123456789", + "verification": null, + "owner": { + "type": "self", + "customer": null + } + } + }, + "livemode": false, + "pending_webhooks": 4, + "request": { + "id": "req_QyDCzn33ls4m1t", + "idempotency_key": null + }, + "type": "customer.tax_id.deleted" +} diff --git a/test/stripe/customer_tax_id_updated.json b/test/stripe/customer_tax_id_updated.json new file mode 100644 index 0000000..a89923d --- /dev/null +++ b/test/stripe/customer_tax_id_updated.json @@ -0,0 +1,30 @@ +{ + "id": "evt_3KtQThJDPojXS6LN0E06aNxq", + "object": "event", + "api_version": "2020-03-02", + "created": 1619701111, + "data": { + "object": { + "id": "txi_1NuMB12eZvKYlo2CMecoWkZd", + "object": "tax_id", + "country": "DE", + "created": 123456789, + "customer": null, + "livemode": false, + "type": "eu_vat", + "value": "DE123456789", + "verification": null, + "owner": { + "type": "self", + "customer": null + } + } + }, + "livemode": false, + "pending_webhooks": 4, + "request": { + "id": "req_QyDCzn33ls4m1t", + "idempotency_key": null + }, + "type": "customer.tax_id.updated" +} diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts index db964ff..5b2a5e6 100644 --- a/test/webhooks.test.ts +++ b/test/webhooks.test.ts @@ -27,6 +27,9 @@ describe('/webhooks', () => { test.each([ 'customer_updated.json', 'customer_deleted.json', + 'customer_tax_id_created.json', + 'customer_tax_id_deleted.json', + 'customer_tax_id_updated.json', 'product_created.json', 'product_deleted.json', 'product_updated.json',