Skip to content

Commit

Permalink
feat: sync credit notes (#113)
Browse files Browse the repository at this point in the history
  • Loading branch information
kevcodez authored Jul 3, 2024
1 parent 8969eb6 commit 8d19019
Show file tree
Hide file tree
Showing 10 changed files with 398 additions and 2 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions db/migrations/0026_credit_notes.sql
Original file line number Diff line number Diff line change
@@ -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);
64 changes: 64 additions & 0 deletions src/lib/creditNotes.ts
Original file line number Diff line number Diff line change
@@ -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<Stripe.CreditNote[]> => {
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)
}
25 changes: 24 additions & 1 deletion src/lib/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -38,6 +39,7 @@ interface SyncBackfill {
disputes?: Sync
charges?: Sync
taxIds?: Sync
creditNotes?: Sync
}

export interface SyncBackfillParams {
Expand All @@ -61,6 +63,7 @@ type SyncObject =
| 'payment_intent'
| 'plan'
| 'tax_id'
| 'credit_note'

export async function syncSingleEntity(stripeId: string) {
if (stripeId.startsWith('cus_')) {
Expand Down Expand Up @@ -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]))
}
}

Expand All @@ -106,7 +111,8 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
charges,
paymentIntents,
plans,
taxIds
taxIds,
creditNotes

switch (object) {
case 'all':
Expand All @@ -122,6 +128,7 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
paymentMethods = await syncPaymentMethods(params)
paymentIntents = await syncPaymentIntents(params)
taxIds = await syncTaxIds(params)
creditNotes = await syncCreditNotes(params)
break
case 'customer':
customers = await syncCustomers(params)
Expand Down Expand Up @@ -161,6 +168,9 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
case 'tax_id':
taxIds = await syncTaxIds(params)
break
case 'credit_note':
creditNotes = await syncCreditNotes(params)
break
default:
break
}
Expand All @@ -179,6 +189,7 @@ export async function syncBackfill(params?: SyncBackfillParams): Promise<SyncBac
paymentIntents,
plans,
taxIds,
creditNotes,
}
}

Expand Down Expand Up @@ -361,6 +372,18 @@ export async function syncDisputes(syncParams?: SyncBackfillParams): Promise<Syn
)
}

export async function syncCreditNotes(syncParams?: SyncBackfillParams): Promise<Sync> {
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<T>(
fetch: () => Stripe.ApiListPromise<T>,
upsert: (items: T[]) => Promise<T[]>
Expand Down
10 changes: 10 additions & 0 deletions src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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')
}
Expand Down
38 changes: 38 additions & 0 deletions src/schemas/credit_note.ts
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions test/stripe/credit_note_created.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading

0 comments on commit 8d19019

Please sign in to comment.