@@ -22,6 +22,70 @@ import type {
2222 VerifiedBillingEvent ,
2323} from './providers/provider.interface'
2424
25+ const NORMALIZED_EVENT_PAYLOAD_KEY = '_normalizedMembershipEvent'
26+
27+ type StoredNormalizedBillingEvent = Omit <
28+ NormalizedBillingEvent ,
29+ 'currentPeriodEnd' | 'occurredAt'
30+ > & {
31+ currentPeriodEnd : string
32+ occurredAt ?: string
33+ }
34+
35+ const storeBillingEventPayload = (
36+ event : NormalizedBillingEvent ,
37+ rawPayload : unknown ,
38+ ) : Record < string , unknown > => {
39+ const payload =
40+ rawPayload && typeof rawPayload === 'object' && ! Array . isArray ( rawPayload )
41+ ? { ...( rawPayload as Record < string , unknown > ) }
42+ : { rawPayload }
43+ const storedEvent : StoredNormalizedBillingEvent = {
44+ ...event ,
45+ currentPeriodEnd : event . currentPeriodEnd . toISOString ( ) ,
46+ occurredAt : event . occurredAt ?. toISOString ( ) ,
47+ }
48+ payload [ NORMALIZED_EVENT_PAYLOAD_KEY ] = storedEvent
49+ return payload
50+ }
51+
52+ const readStoredBillingEvent = (
53+ payload : unknown ,
54+ ) : NormalizedBillingEvent | null => {
55+ if ( ! payload || typeof payload !== 'object' ) return null
56+ const stored = ( payload as Record < string , unknown > ) [
57+ NORMALIZED_EVENT_PAYLOAD_KEY
58+ ] as Partial < StoredNormalizedBillingEvent > | undefined
59+ if (
60+ ! stored ||
61+ typeof stored . eventId !== 'string' ||
62+ typeof stored . provider !== 'string' ||
63+ typeof stored . type !== 'string' ||
64+ typeof stored . customerId !== 'string' ||
65+ typeof stored . subscriptionId !== 'string' ||
66+ typeof stored . currentPeriodEnd !== 'string' ||
67+ typeof stored . readerId !== 'string'
68+ ) {
69+ return null
70+ }
71+ const currentPeriodEnd = new Date ( stored . currentPeriodEnd )
72+ const occurredAt = stored . occurredAt ? new Date ( stored . occurredAt ) : undefined
73+ if (
74+ Number . isNaN ( currentPeriodEnd . getTime ( ) ) ||
75+ ( occurredAt && Number . isNaN ( occurredAt . getTime ( ) ) )
76+ ) {
77+ return null
78+ }
79+ return {
80+ ...( stored as Omit <
81+ NormalizedBillingEvent ,
82+ 'currentPeriodEnd' | 'occurredAt'
83+ > ) ,
84+ currentPeriodEnd,
85+ occurredAt,
86+ }
87+ }
88+
2589const isLiveProviderSubscription = ( row : MembershipRow ) : boolean => {
2690 if ( row . provider === 'manual' ) return false
2791 if ( row . status !== 'active' && row . status !== 'on_hold' ) return false
@@ -115,6 +179,11 @@ export class MembershipService {
115179 rawPayload : input . decoded ,
116180 rawType : 'apple.confirm' ,
117181 } )
182+ await this . replayDeferredEvents (
183+ event . provider ,
184+ event . subscriptionId ,
185+ input . readerId ,
186+ )
118187
119188 return this . toStatusResult (
120189 await this . membershipRepository . findByReaderId ( input . readerId ) ,
@@ -133,7 +202,7 @@ export class MembershipService {
133202 provider : event . provider ,
134203 eventId : event . eventId ,
135204 type : rawType ,
136- payload : rawPayload ,
205+ payload : storeBillingEventPayload ( event , rawPayload ) ,
137206 } )
138207
139208 if ( ! webhookEventRow ) {
@@ -161,9 +230,57 @@ export class MembershipService {
161230 return { applied }
162231 }
163232
233+ async deferEvent ( verifiedEvent : VerifiedBillingEvent ) : Promise < void > {
234+ const { event, rawPayload, rawType } = verifiedEvent
235+ await this . billingWebhookEventRepository . create ( {
236+ provider : event . provider ,
237+ eventId : event . eventId ,
238+ type : rawType ,
239+ payload : storeBillingEventPayload ( event , rawPayload ) ,
240+ } )
241+ }
242+
243+ private async replayDeferredEvents (
244+ provider : string ,
245+ subscriptionId : string ,
246+ readerId : string ,
247+ ) : Promise < void > {
248+ const rows =
249+ await this . billingWebhookEventRepository . findPendingByProviderSubscriptionId (
250+ provider ,
251+ subscriptionId ,
252+ )
253+ for ( const row of rows ) {
254+ const storedEvent = readStoredBillingEvent ( row . payload )
255+ if ( storedEvent ) {
256+ await this . applyMembershipState ( { ...storedEvent , readerId } )
257+ }
258+ await this . billingWebhookEventRepository . markProcessed ( row . id , new Date ( ) )
259+ }
260+ }
261+
262+ private async isSupersededEvent (
263+ event : NormalizedBillingEvent ,
264+ ) : Promise < boolean > {
265+ if ( ! event . occurredAt ) return false
266+ const latestRow =
267+ await this . billingWebhookEventRepository . findLatestProcessedByProviderSubscriptionId (
268+ event . provider ,
269+ event . subscriptionId ,
270+ )
271+ if ( ! latestRow ) return false
272+ const latestEvent = readStoredBillingEvent ( latestRow . payload )
273+ return Boolean (
274+ latestEvent ?. occurredAt &&
275+ latestEvent . occurredAt . getTime ( ) >= event . occurredAt . getTime ( ) ,
276+ )
277+ }
278+
164279 private async applyMembershipState (
165280 event : NormalizedBillingEvent ,
166281 ) : Promise < boolean > {
282+ if ( await this . isSupersededEvent ( event ) ) return false
283+
167284 let existing = await this . membershipRepository . findByProviderSubscriptionId (
168285 event . subscriptionId ,
169286 )
0 commit comments