@@ -543,6 +543,98 @@ describe('MembershipService', () => {
543543 expect ( result ) . toMatchObject ( { provider : 'dodo' , status : 'active' } )
544544 } )
545545
546+ it ( 'returns an existing active manual membership without rewriting it' , async ( ) => {
547+ const { service, membershipRepository } = createService ( )
548+ const live = createMembership ( {
549+ provider : 'manual' ,
550+ providerCustomerId : null ,
551+ providerSubscriptionId : null ,
552+ } )
553+ membershipRepository . findByReaderId . mockResolvedValue ( live )
554+
555+ const result = await service . confirmAppleTransaction ( {
556+ decoded,
557+ readerId : 'reader-1' ,
558+ ...products ,
559+ } )
560+
561+ expect ( membershipRepository . create ) . not . toHaveBeenCalled ( )
562+ expect ( membershipRepository . update ) . not . toHaveBeenCalled ( )
563+ expect ( result ) . toMatchObject ( { provider : 'manual' , status : 'active' } )
564+ } )
565+
566+ it ( 'rebinds an inactive non-Apple row to the confirmed Apple subscription' , async ( ) => {
567+ const { service, membershipRepository } = createService ( )
568+ const inactive = createMembership ( {
569+ provider : 'dodo' ,
570+ status : 'cancelled' ,
571+ currentPeriodEnd : new Date ( now . getTime ( ) - 1_000 ) ,
572+ } )
573+ const rebound = createMembership ( {
574+ provider : 'apple' ,
575+ providerCustomerId : 'orig-apple' ,
576+ providerSubscriptionId : 'orig-apple' ,
577+ plan : 'monthly' ,
578+ status : 'active' ,
579+ currentPeriodEnd : new Date ( decoded . expiresDate ) ,
580+ } )
581+ membershipRepository . findByReaderId
582+ . mockResolvedValueOnce ( inactive )
583+ . mockResolvedValueOnce ( inactive )
584+ . mockResolvedValueOnce ( rebound )
585+
586+ const result = await service . confirmAppleTransaction ( {
587+ decoded,
588+ readerId : 'reader-1' ,
589+ ...products ,
590+ } )
591+
592+ expect ( membershipRepository . update ) . toHaveBeenCalledWith (
593+ inactive . id ,
594+ expect . objectContaining ( {
595+ plan : 'monthly' ,
596+ provider : 'apple' ,
597+ providerSubscriptionId : 'orig-apple' ,
598+ status : 'active' ,
599+ } ) ,
600+ )
601+ expect ( result ) . toMatchObject ( { provider : 'apple' , status : 'active' } )
602+ } )
603+
604+ it ( 'does not reactivate a cancelled Apple membership when confirmation is replayed' , async ( ) => {
605+ const { service, membershipRepository, billingWebhookEventRepository } =
606+ createService ( )
607+ const cancelled = createMembership ( {
608+ provider : 'apple' ,
609+ providerCustomerId : 'orig-apple' ,
610+ providerSubscriptionId : 'orig-apple' ,
611+ status : 'cancelled' ,
612+ } )
613+ membershipRepository . findByProviderSubscriptionId . mockResolvedValue (
614+ cancelled ,
615+ )
616+ membershipRepository . findByReaderId . mockResolvedValue ( cancelled )
617+ billingWebhookEventRepository . create . mockResolvedValue ( null )
618+ billingWebhookEventRepository . findByProviderAndEventId . mockResolvedValue ( {
619+ id : 'event-apple' as any ,
620+ provider : 'apple' ,
621+ eventId : decoded . transactionId ,
622+ type : 'apple.confirm' ,
623+ payload : decoded ,
624+ processedAt : now ,
625+ receivedAt : now ,
626+ } )
627+
628+ const result = await service . confirmAppleTransaction ( {
629+ decoded,
630+ readerId : 'reader-1' ,
631+ ...products ,
632+ } )
633+
634+ expect ( membershipRepository . update ) . not . toHaveBeenCalled ( )
635+ expect ( result ) . toMatchObject ( { provider : 'apple' , status : 'cancelled' } )
636+ } )
637+
546638 it ( 'rejects when the originalTransactionId is bound to another reader' , async ( ) => {
547639 const { service, membershipRepository } = createService ( )
548640 membershipRepository . findByProviderSubscriptionId . mockResolvedValue (
0 commit comments