From 8d19019f7a33a4de5ac42bca8e1bf1cde8cba536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Thu, 4 Jul 2024 00:01:07 +0800 Subject: [PATCH] feat: sync credit notes (#113) --- README.md | 5 +- db/migrations/0026_credit_notes.sql | 36 ++++++++++++++ src/lib/creditNotes.ts | 64 ++++++++++++++++++++++++ src/lib/sync.ts | 25 +++++++++- src/routes/webhooks.ts | 10 ++++ src/schemas/credit_note.ts | 38 +++++++++++++++ test/stripe/credit_note_created.json | 73 ++++++++++++++++++++++++++++ test/stripe/credit_note_updated.json | 73 ++++++++++++++++++++++++++++ test/stripe/credit_note_voided.json | 73 ++++++++++++++++++++++++++++ test/webhooks.test.ts | 3 ++ 10 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 db/migrations/0026_credit_notes.sql create mode 100644 src/lib/creditNotes.ts create mode 100644 src/schemas/credit_note.ts create mode 100644 test/stripe/credit_note_created.json create mode 100644 test/stripe/credit_note_updated.json create mode 100644 test/stripe/credit_note_voided.json diff --git a/README.md b/README.md index cc0ce58..81528ae 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ This server synchronizes your Stripe account to a Postgres database. It can be a - [ ] `checkout.session.async_payment_failed` - [ ] `checkout.session.async_payment_succeeded` - [ ] `checkout.session.completed` +- [x] `credit_note.created` 🟢 +- [x] `credit_note.updated` 🟢 +- [x] `credit_note.voided` 🟢 - [x] `customer.created` 🟢 - [x] `customer.deleted` 🟢 - [ ] `customer.source.created` @@ -129,7 +132,7 @@ body: { } ``` -- `object` **all** | **charge** | **customer** | **dispute** | **invoice** | **payment_method** | **payment_intent** | **plan** | **price** | **product** | **setup_intent** | **subscription** +- `object` **all** | **charge** | **customer** | **dispute** | **invoice** | **payment_method** | **payment_intent** | **plan** | **price** | **product** | **setup_intent** | **subscription** - `created` is Stripe.RangeQueryParam. It supports **gt**, **gte**, **lt**, **lte** #### Alternative routes to sync `daily/weekly/monthly` data diff --git a/db/migrations/0026_credit_notes.sql b/db/migrations/0026_credit_notes.sql new file mode 100644 index 0000000..11b04bf --- /dev/null +++ b/db/migrations/0026_credit_notes.sql @@ -0,0 +1,36 @@ +create table if not exists + "stripe"."credit_notes" ( + "id" text primary key, + object text, + amount integer, + amount_shipping integer, + created integer, + currency text, + customer text, + customer_balance_transaction text, + discount_amount integer, + discount_amounts jsonb, + invoice text, + lines jsonb, + livemode boolean, + memo text, + metadata jsonb, + number text, + out_of_band_amount integer, + pdf text, + reason text, + refund text, + shipping_cost jsonb, + status text, + subtotal integer, + subtotal_excluding_tax integer, + tax_amounts jsonb, + total integer, + total_excluding_tax integer, + type text, + voided_at text + ); + +create index stripe_credit_notes_customer_idx on "stripe"."credit_notes" using btree (customer); + +create index stripe_credit_notes_invoice_idx on "stripe"."credit_notes" using btree (invoice); \ No newline at end of file diff --git a/src/lib/creditNotes.ts b/src/lib/creditNotes.ts new file mode 100644 index 0000000..594bb4c --- /dev/null +++ b/src/lib/creditNotes.ts @@ -0,0 +1,64 @@ +import Stripe from 'stripe' +import { getConfig } from '../utils/config' +import { constructUpsertSql } from '../utils/helpers' +import { backfillInvoices } from './invoices' +import { backfillCustomers } from './customers' +import { findMissingEntries, getUniqueIds, upsertMany } from './database_utils' +import { stripe } from '../utils/StripeClientManager' +import { creditNoteSchema } from '../schemas/credit_note' + +const config = getConfig() + +export const upsertCreditNotes = async ( + creditNotes: Stripe.CreditNote[], + backfillRelatedEntities: boolean = true +): Promise => { + if (backfillRelatedEntities) { + await Promise.all([ + backfillCustomers(getUniqueIds(creditNotes, 'customer')), + backfillInvoices(getUniqueIds(creditNotes, 'invoice')), + ]) + } + + // Stripe only sends the first 10 refunds by default, the option will actively fetch all refunds + if (getConfig().AUTO_EXPAND_LISTS) { + for (const creditNote of creditNotes) { + if (creditNote.lines?.has_more) { + const allLines: Stripe.CreditNoteLineItem[] = [] + for await (const lineItem of stripe.creditNotes.listLineItems(creditNote.id, { + limit: 100, + })) { + allLines.push(lineItem) + } + + creditNote.lines = { + ...creditNote.lines, + data: allLines, + has_more: false, + } + } + } + } + + return upsertMany(creditNotes, () => + constructUpsertSql(config.SCHEMA, 'credit_notes', creditNoteSchema) + ) +} + +export const backfillCreditNotes = async (creditNoteIds: string[]) => { + const missingCreditNoteIds = await findMissingEntries('credit_notes', creditNoteIds) + await fetchAndInsertCreditNotes(missingCreditNoteIds) +} + +const fetchAndInsertCreditNotes = async (creditNoteIds: string[]) => { + if (!creditNoteIds.length) return + + const creditNotes: Stripe.CreditNote[] = [] + + for (const creditNoteId of creditNoteIds) { + const creditNote = await stripe.creditNotes.retrieve(creditNoteId) + creditNotes.push(creditNote) + } + + await upsertCreditNotes(creditNotes, true) +} diff --git a/src/lib/sync.ts b/src/lib/sync.ts index d64dece..0b4cc5e 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -17,6 +17,7 @@ import { upsertPlans } from './plans' import { upsertSubscriptionSchedules } from './subscription_schedules' import pLimit from 'p-limit' import { upsertTaxIds } from './tax_ids' +import { upsertCreditNotes } from './creditNotes' const config = getConfig() @@ -38,6 +39,7 @@ interface SyncBackfill { disputes?: Sync charges?: Sync taxIds?: Sync + creditNotes?: Sync } export interface SyncBackfillParams { @@ -61,6 +63,7 @@ type SyncObject = | 'payment_intent' | 'plan' | 'tax_id' + | 'credit_note' export async function syncSingleEntity(stripeId: string) { if (stripeId.startsWith('cus_')) { @@ -89,6 +92,8 @@ export async function syncSingleEntity(stripeId: string) { return stripe.paymentIntents.retrieve(stripeId).then((it) => upsertPaymentIntents([it])) } else if (stripeId.startsWith('txi_')) { return stripe.taxIds.retrieve(stripeId).then((it) => upsertTaxIds([it])) + } else if (stripeId.startsWith('cn_')) { + return stripe.creditNotes.retrieve(stripeId).then((it) => upsertCreditNotes([it])) } } @@ -106,7 +111,8 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise { + console.log('Syncing credit notes') + + const params: Stripe.CreditNoteListParams = { limit: 100 } + if (syncParams?.created) params.created = syncParams?.created + + return fetchAndUpsert( + () => stripe.creditNotes.list(params), + (creditNotes) => upsertCreditNotes(creditNotes) + ) +} + async function fetchAndUpsert( fetch: () => Stripe.ApiListPromise, upsert: (items: T[]) => Promise diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index b653164..d30f5b2 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -15,6 +15,7 @@ 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' +import { upsertCreditNotes } from '../lib/creditNotes' const config = getConfig() @@ -180,6 +181,15 @@ export default async function routes(fastify: FastifyInstance) { break } + case 'credit_note.created': + case 'credit_note.updated': + case 'credit_note.voided': { + const creditNote = event.data.object as Stripe.CreditNote + + await upsertCreditNotes([creditNote]) + break + } + default: throw new Error('Unhandled webhook event') } diff --git a/src/schemas/credit_note.ts b/src/schemas/credit_note.ts new file mode 100644 index 0000000..4bbc5f0 --- /dev/null +++ b/src/schemas/credit_note.ts @@ -0,0 +1,38 @@ +import { JsonSchema } from '../types/types' + +export const creditNoteSchema: JsonSchema = { + $id: 'creditNoteSchema', + type: 'object', + properties: { + id: { type: 'string' }, + object: { type: 'string' }, + amount: { type: 'number' }, + amount_shipping: { type: 'number' }, + created: { type: 'number' }, + currency: { type: 'string' }, + customer: { type: 'string' }, + customer_balance_transaction: { type: 'string' }, + discount_amount: { type: 'number' }, + discount_amounts: { type: 'object' }, + invoice: { type: 'string' }, + lines: { type: 'object' }, + livemode: { type: 'boolean' }, + memo: { type: 'string' }, + metadata: { type: 'object' }, + number: { type: 'string' }, + out_of_band_amount: { type: 'number' }, + pdf: { type: 'string' }, + reason: { type: 'string' }, + refund: { type: 'string' }, + shipping_cost: { type: 'object' }, + status: { type: 'string' }, + subtotal: { type: 'number' }, + subtotal_excluding_tax: { type: 'number' }, + tax_amounts: { type: 'object' }, + total: { type: 'number' }, + total_excluding_tax: { type: 'number' }, + type: { type: 'string' }, + voided_at: { type: 'string' }, + }, + required: ['id'], +} as const diff --git a/test/stripe/credit_note_created.json b/test/stripe/credit_note_created.json new file mode 100644 index 0000000..5e0bc42 --- /dev/null +++ b/test/stripe/credit_note_created.json @@ -0,0 +1,73 @@ +{ + "id": "evt_1KJrLuJDPojXS6LNKLCh0CEr", + "object": "event", + "api_version": "2020-03-02", + "created": 1642649422, + "data": { + "object": { + "id": "cn_1MxvRqLkdIwHu7ixY0xbUcxk", + "object": "credit_note", + "amount": 1099, + "amount_shipping": 0, + "created": 1681750958, + "currency": "usd", + "customer": "cus_NjLgPhUokHubJC", + "customer_balance_transaction": null, + "discount_amount": 0, + "discount_amounts": [], + "invoice": "in_1MxvRkLkdIwHu7ixABNtI99m", + "lines": { + "object": "list", + "data": [ + { + "id": "cnli_1MxvRqLkdIwHu7ixFpdhBFQf", + "object": "credit_note_line_item", + "amount": 1099, + "amount_excluding_tax": 1099, + "description": "T-shirt", + "discount_amount": 0, + "discount_amounts": [], + "invoice_line_item": "il_1MxvRlLkdIwHu7ixnkbntxUV", + "livemode": false, + "quantity": 1, + "tax_amounts": [], + "tax_rates": [], + "type": "invoice_line_item", + "unit_amount": 1099, + "unit_amount_decimal": "1099", + "unit_amount_excluding_tax": "1099" + } + ], + "has_more": false, + "url": "/v1/credit_notes/cn_1MxvRqLkdIwHu7ixY0xbUcxk/lines" + }, + "livemode": false, + "memo": null, + "metadata": {}, + "number": "C9E0C52C-0036-CN-01", + "out_of_band_amount": null, + "pdf": "https://pay.stripe.com/credit_notes/acct_1M2JTkLkdIwHu7ix/test_YWNjdF8xTTJKVGtMa2RJd0h1N2l4LF9Oak9FOUtQNFlPdk52UXhFd2Z4SU45alpEd21kd0Y4LDcyMjkxNzU50200cROQsSK2/pdf?s=ap", + "reason": null, + "refund": null, + "shipping_cost": null, + "status": "issued", + "subtotal": 1099, + "subtotal_excluding_tax": 1099, + "tax_amounts": [], + "total": 1099, + "total_excluding_tax": 1099, + "type": "pre_payment", + "voided_at": null + }, + "previous_attributes": { + "custom_fields": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_m87bnWeVxyQPx0", + "idempotency_key": "010d8300-b837-46e0-a795-6247dd0e05e1" + }, + "type": "credit_note.created" +} diff --git a/test/stripe/credit_note_updated.json b/test/stripe/credit_note_updated.json new file mode 100644 index 0000000..965862d --- /dev/null +++ b/test/stripe/credit_note_updated.json @@ -0,0 +1,73 @@ +{ + "id": "evt_1KJrLuJDPojXS6LNKLCh0CEr", + "object": "event", + "api_version": "2020-03-02", + "created": 1642649422, + "data": { + "object": { + "id": "cn_1MxvRqLkdIwHu7ixY0xbUcxk", + "object": "credit_note", + "amount": 1099, + "amount_shipping": 0, + "created": 1681750958, + "currency": "usd", + "customer": "cus_NjLgPhUokHubJC", + "customer_balance_transaction": null, + "discount_amount": 0, + "discount_amounts": [], + "invoice": "in_1MxvRkLkdIwHu7ixABNtI99m", + "lines": { + "object": "list", + "data": [ + { + "id": "cnli_1MxvRqLkdIwHu7ixFpdhBFQf", + "object": "credit_note_line_item", + "amount": 1099, + "amount_excluding_tax": 1099, + "description": "T-shirt", + "discount_amount": 0, + "discount_amounts": [], + "invoice_line_item": "il_1MxvRlLkdIwHu7ixnkbntxUV", + "livemode": false, + "quantity": 1, + "tax_amounts": [], + "tax_rates": [], + "type": "invoice_line_item", + "unit_amount": 1099, + "unit_amount_decimal": "1099", + "unit_amount_excluding_tax": "1099" + } + ], + "has_more": false, + "url": "/v1/credit_notes/cn_1MxvRqLkdIwHu7ixY0xbUcxk/lines" + }, + "livemode": false, + "memo": null, + "metadata": {}, + "number": "C9E0C52C-0036-CN-01", + "out_of_band_amount": null, + "pdf": "https://pay.stripe.com/credit_notes/acct_1M2JTkLkdIwHu7ix/test_YWNjdF8xTTJKVGtMa2RJd0h1N2l4LF9Oak9FOUtQNFlPdk52UXhFd2Z4SU45alpEd21kd0Y4LDcyMjkxNzU50200cROQsSK2/pdf?s=ap", + "reason": null, + "refund": null, + "shipping_cost": null, + "status": "issued", + "subtotal": 1099, + "subtotal_excluding_tax": 1099, + "tax_amounts": [], + "total": 1099, + "total_excluding_tax": 1099, + "type": "pre_payment", + "voided_at": null + }, + "previous_attributes": { + "custom_fields": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_m87bnWeVxyQPx0", + "idempotency_key": "010d8300-b837-46e0-a795-6247dd0e05e1" + }, + "type": "credit_note.updated" +} diff --git a/test/stripe/credit_note_voided.json b/test/stripe/credit_note_voided.json new file mode 100644 index 0000000..f5ab414 --- /dev/null +++ b/test/stripe/credit_note_voided.json @@ -0,0 +1,73 @@ +{ + "id": "evt_1KJrLuJDPojXS6LNKLCh0CEr", + "object": "event", + "api_version": "2020-03-02", + "created": 1642649422, + "data": { + "object": { + "id": "cn_1MxvRqLkdIwHu7ixY0xbUcxk", + "object": "credit_note", + "amount": 1099, + "amount_shipping": 0, + "created": 1681750958, + "currency": "usd", + "customer": "cus_NjLgPhUokHubJC", + "customer_balance_transaction": null, + "discount_amount": 0, + "discount_amounts": [], + "invoice": "in_1MxvRkLkdIwHu7ixABNtI99m", + "lines": { + "object": "list", + "data": [ + { + "id": "cnli_1MxvRqLkdIwHu7ixFpdhBFQf", + "object": "credit_note_line_item", + "amount": 1099, + "amount_excluding_tax": 1099, + "description": "T-shirt", + "discount_amount": 0, + "discount_amounts": [], + "invoice_line_item": "il_1MxvRlLkdIwHu7ixnkbntxUV", + "livemode": false, + "quantity": 1, + "tax_amounts": [], + "tax_rates": [], + "type": "invoice_line_item", + "unit_amount": 1099, + "unit_amount_decimal": "1099", + "unit_amount_excluding_tax": "1099" + } + ], + "has_more": false, + "url": "/v1/credit_notes/cn_1MxvRqLkdIwHu7ixY0xbUcxk/lines" + }, + "livemode": false, + "memo": null, + "metadata": {}, + "number": "C9E0C52C-0036-CN-01", + "out_of_band_amount": null, + "pdf": "https://pay.stripe.com/credit_notes/acct_1M2JTkLkdIwHu7ix/test_YWNjdF8xTTJKVGtMa2RJd0h1N2l4LF9Oak9FOUtQNFlPdk52UXhFd2Z4SU45alpEd21kd0Y4LDcyMjkxNzU50200cROQsSK2/pdf?s=ap", + "reason": null, + "refund": null, + "shipping_cost": null, + "status": "issued", + "subtotal": 1099, + "subtotal_excluding_tax": 1099, + "tax_amounts": [], + "total": 1099, + "total_excluding_tax": 1099, + "type": "pre_payment", + "voided_at": null + }, + "previous_attributes": { + "custom_fields": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_m87bnWeVxyQPx0", + "idempotency_key": "010d8300-b837-46e0-a795-6247dd0e05e1" + }, + "type": "credit_note.voided" +} diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts index 5b2a5e6..ecfa23f 100644 --- a/test/webhooks.test.ts +++ b/test/webhooks.test.ts @@ -82,6 +82,9 @@ describe('/webhooks', () => { 'payment_intent_processing', 'payment_intent_requires_action', 'payment_intent_succeeded', + 'credit_note_created', + 'credit_note_updated', + 'credit_note_voided', ])('process event %s', async (jsonFile) => { const eventBody = await import(`./stripe/${jsonFile}`).then(({ default: myData }) => myData) const signature = createHmac('sha256', stripeWebhookSecret)