1- import { describe , it , expect , beforeAll , afterAll } from 'vitest' ;
1+ import { describe , it , expect , beforeAll , afterAll , vi , afterEach } from 'vitest' ;
22import { FastifyInstance } from 'fastify' ;
33import path from 'node:path' ;
44import pino from 'pino' ;
@@ -18,6 +18,7 @@ describe('POST /webhooks', () => {
1818 let orbSync : OrbSync ;
1919
2020 beforeAll ( async ( ) => {
21+ process . env . TZ = 'UTC' ; // Ensure consistent timezone for tests
2122 const logger = pino ( { level : 'silent' } ) ;
2223
2324 // Create a OrbSync instance for integration testing
@@ -45,6 +46,10 @@ describe('POST /webhooks', () => {
4546 }
4647 } ) ;
4748
49+ afterEach ( ( ) => {
50+ vi . restoreAllMocks ( ) ;
51+ } ) ;
52+
4853 function loadWebhookPayload ( eventName : string ) : string {
4954 const fixturePath = path . join ( __dirname , 'orb' , `${ eventName } .json` ) ;
5055 return fs . readFileSync ( fixturePath , 'utf-8' ) ;
@@ -171,6 +176,86 @@ describe('POST /webhooks', () => {
171176 ) ;
172177 } ) ;
173178
179+ it ( 'should handle invoice.issued webhook and resync subscription if billing cycle outdated' , async ( ) => {
180+ let payload = loadWebhookPayload ( 'invoice' ) ;
181+
182+ // Parse the payload and update billing cycle dates to sensible values
183+ const webhookData = JSON . parse ( payload ) ;
184+ const invoiceId = webhookData . invoice . id ;
185+ const subscriptionId = webhookData . invoice . subscription ?. id ;
186+ const customerId = webhookData . invoice . customer . id ;
187+
188+ // As preparation, we delete the existing invoice and subscription if they exist
189+ await deleteTestData ( orbSync . postgresClient , 'invoices' , [ invoiceId ] ) ;
190+ await deleteTestData ( orbSync . postgresClient , 'subscriptions' , [ subscriptionId ] ) ;
191+
192+ webhookData . type = 'invoice.issued' ;
193+
194+ const now = new Date ( ) ;
195+ const oneDayAgo = new Date ( now . getTime ( ) - 24 * 60 * 60 * 1000 ) ; // 1 day ago
196+ const thirtyOneDaysAgo = new Date ( now . getTime ( ) - 31 * 24 * 60 * 60 * 1000 ) ; // 31 days ago
197+ const thirtyDaysInFuture = new Date ( now . getTime ( ) + 30 * 24 * 60 * 60 * 1000 ) ; // 30 days in future
198+
199+ // Find and update the plan and change to in_arrear
200+ const planLineItem = webhookData . invoice . line_items . find (
201+ ( item : Invoice . LineItem ) =>
202+ item . price ?. price_type === 'fixed_price' && item . price . billable_metric === null && item . name . endsWith ( 'Plan' )
203+ ) ;
204+ planLineItem . price . billing_mode = 'in_arrear' ;
205+
206+ // Update the payload with the modified data
207+ payload = JSON . stringify ( webhookData ) ;
208+
209+ // For an outdated billing cycle, the webhook handler resyncs the subscription from the Orb API
210+ // to get the new billing cycle. We mock that fetch to return the subscription with the
211+ // updated billing period dates.
212+ const testSubscription = {
213+ id : subscriptionId ,
214+ customer : { id : customerId } ,
215+ status : 'active' ,
216+ current_billing_period_start_date : thirtyOneDaysAgo . toISOString ( ) ,
217+ // Billing cycle end date in past
218+ current_billing_period_end_date : oneDayAgo . toISOString ( ) ,
219+ billing_cycle_day : 8 ,
220+ net_terms : 0 ,
221+ metadata : { } ,
222+ created_at : new Date ( ) . toISOString ( ) ,
223+ start_date : new Date ( ) . toISOString ( ) ,
224+ } as Subscription ;
225+
226+ syncSubscriptions ( orbSync . postgresClient , [ testSubscription ] ) ;
227+
228+ // Mock the Orb SDK call that fetches the subscription during the in-arrears resync
229+ const orb = ( orbSync as unknown as { orb : { subscriptions : { fetch : ( id : string ) => Promise < Subscription > } } } )
230+ . orb ;
231+
232+ const latestSubscription = {
233+ ...testSubscription ,
234+ current_billing_period_start_date : oneDayAgo . toISOString ( ) ,
235+ current_billing_period_end_date : thirtyDaysInFuture . toISOString ( ) ,
236+ } ;
237+ const fetchSpy = vi . spyOn ( orb . subscriptions , 'fetch' ) . mockResolvedValue ( latestSubscription ) ;
238+
239+ const response = await sendWebhookRequest ( payload ) ;
240+ expect ( fetchSpy ) . toHaveBeenCalledWith ( subscriptionId ) ;
241+ expect ( response . statusCode ) . toBe ( 200 ) ;
242+
243+ // Verify that the invoice was created in the database
244+ const [ invoice ] = await fetchInvoicesFromDatabase ( orbSync . postgresClient , [ invoiceId ] ) ;
245+ expect ( invoice ) . toBeDefined ( ) ;
246+
247+ // Verify that billing cycle was updated if subscription exists and has a plan line item
248+ const billingCycles = await fetchBillingCyclesFromDatabase ( orbSync . postgresClient , subscriptionId ) ;
249+ expect ( billingCycles ) . toHaveLength ( 1 ) ;
250+ const billingCycle = billingCycles [ 0 ] ;
251+
252+ // The billing cycle should reflect the resynced subscription's billing period (startDate/endDate),
253+ // proving the in-arrears branch fetched the subscription from the Orb API rather than deriving the
254+ // cycle from the invoice line item.
255+ expect ( new Date ( billingCycle . current_billing_period_start_date ) . toISOString ( ) ) . toBe ( oneDayAgo . toISOString ( ) ) ;
256+ expect ( new Date ( billingCycle . current_billing_period_end_date ) . toISOString ( ) ) . toBe ( thirtyDaysInFuture . toISOString ( ) ) ;
257+ } ) ;
258+
174259 it . each ( [
175260 'invoice.edited' ,
176261 'invoice.manually_marked_as_void' ,
0 commit comments