File: /home/xedaptot/ai.naniguide.com/tests/Unit/RemoteSubscription/RemoteSubscriptionSyncServiceTest.php
<?php
namespace Tests\Unit\RemoteSubscription;
use Tests\TestCase;
use Mockery;
use App\Library\RemoteSubscriptionSyncService;
use App\Library\DTOs\RemoteSyncResult;
use App\Cashier\DTO\RemoteSubscriptionDTO;
use App\Cashier\DTO\RemotePlanDTO;
use App\Cashier\Contracts\IntentGatewayInterface;
use App\Cashier\Contracts\RemoteSubscriptionGatewayInterface;
use App\Model\Subscription;
use App\Model\PaymentGateway;
use App\Model\PlanRemoteMapping;
use App\Model\Plan;
use App\Library\Facades\Billing;
use App\Library\Facades\SubscriptionFacade;
use App\Services\Subscription\SubscriptionManagementService;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class RemoteSubscriptionSyncServiceTest extends TestCase
{
private RemoteSubscriptionSyncService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new RemoteSubscriptionSyncService();
}
// ==========================================
// syncSubscription tests
// ==========================================
public function test_sync_returns_error_when_no_remote_subscription()
{
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(false);
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_ERROR, $result->status);
$this->assertStringContainsString('No remote subscription', $result->error);
}
public function test_sync_returns_error_when_gateway_not_remote()
{
// IntentGatewayInterface (required by PaymentGateway::getService() return type)
// but NOT RemoteSubscriptionGatewayInterface — exercises the "no remote support" guard.
$nonRemoteService = Mockery::mock(IntentGatewayInterface::class);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($nonRemoteService);
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->remoteGateway = $gateway;
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_ERROR, $result->status);
$this->assertStringContainsString('does not support remote subscriptions', $result->error);
}
public function test_sync_returns_in_sync_when_no_changes()
{
$periodEnd = Carbon::parse('2025-09-01 00:00:00');
$remotePlanId = 'price_abc123';
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'active',
remotePlanId: $remotePlanId,
remoteCustomerId: 'cus_test',
currentPeriodEnd: $periodEnd->copy(),
currentPeriodStart: Carbon::parse('2025-08-01 00:00:00'),
canceledAt: null,
latestInvoiceAmount: 29.99,
latestInvoiceStatus: 'paid',
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->with('sub_test')->andReturn($remoteDTO);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Test Stripe Gateway';
$gateway->type = 'stripe-subscription';
// Use attribute map to avoid Eloquent getAttribute/setAttribute issues with Mockery
$attrs = [
'remoteGateway' => $gateway,
'plan' => null,
'status' => 'active',
'uid' => 'sub_uid_123',
'remote_subscription_id' => 'sub_test',
'current_period_ends_at' => $periodEnd->copy(),
'plan_id' => null,
'remote_metadata' => [
'remote_plan_id' => $remotePlanId,
'latest_invoice_amount' => 29.99,
],
'cancelled_at' => null,
'last_synced_at' => null,
];
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('isActive')->andReturn(true);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
// Route all attribute access through our map
$subscription->shouldReceive('getAttribute')->andReturnUsing(function ($key) use (&$attrs) {
return $attrs[$key] ?? null;
});
$subscription->shouldReceive('setAttribute')->andReturnUsing(function ($key, $value) use (&$attrs, $subscription) {
$attrs[$key] = $value;
return $subscription;
});
// Allow direct property access for Mockery
$subscription->uid = 'sub_uid_123';
// Mock SubscriptionFacade::log
SubscriptionFacade::shouldReceive('log')->once();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_IN_SYNC, $result->status);
$this->assertEmpty($result->changes);
}
public function test_sync_detects_cancelled_remote_subscription()
{
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'canceled',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: Carbon::now()->addDays(15),
currentPeriodStart: Carbon::now()->subDays(15),
canceledAt: Carbon::now()->subHours(2),
latestInvoiceAmount: 29.99,
latestInvoiceStatus: 'paid',
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(false);
$subscription->shouldReceive('isActive')->andReturn(true);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('setCancelled')->once();
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'active';
$subscription->uid = 'sub_uid_cancel';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = Carbon::now()->addDays(15);
$subscription->plan_id = null;
$subscription->remote_metadata = ['remote_plan_id' => 'price_abc'];
SubscriptionFacade::shouldReceive('log')->once();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_DRIFTED, $result->status);
$this->assertArrayHasKey('status', $result->changes);
$this->assertEquals('cancelled_remote', $result->changes['status']['new']);
}
public function test_sync_detects_past_due_remote()
{
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'past_due',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: Carbon::now()->addDays(5),
currentPeriodStart: Carbon::now()->subDays(25),
canceledAt: null,
latestInvoiceAmount: 29.99,
latestInvoiceStatus: 'open',
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(false);
$subscription->shouldReceive('isActive')->andReturn(true);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'active';
$subscription->uid = 'sub_uid_pd';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = Carbon::now()->addDays(5);
$subscription->plan_id = null;
$subscription->remote_metadata = ['remote_plan_id' => 'price_abc', 'latest_invoice_amount' => 29.99];
SubscriptionFacade::shouldReceive('log')->once();
Log::shouldReceive('warning')->zeroOrMoreTimes();
Log::shouldReceive('info')->zeroOrMoreTimes();
$result = $this->service->syncSubscription($subscription);
// Past due now records a status change (drifted)
$this->assertEquals(RemoteSyncResult::STATUS_DRIFTED, $result->status);
$this->assertArrayHasKey('status', $result->changes);
$this->assertEquals('past_due_remote', $result->changes['status']['new']);
$this->assertTrue($result->hasWarnings());
$this->assertEquals(Subscription::REMOTE_RECONCILE_STATUS_STATE_MISMATCH, $subscription->remote_reconcile_status);
$warningTexts = implode(' ', $result->warnings);
$this->assertStringContainsString('past_due', $warningTexts);
}
public function test_sync_returns_remote_not_found_on_api_exception()
{
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')
->andThrow(new \Exception('Subscription not found in Stripe'));
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->uid = 'sub_uid_nf';
$subscription->remote_subscription_id = 'sub_nonexistent';
// logSyncError uses both Log::error and SubscriptionFacade::log
Log::shouldReceive('error')->once();
SubscriptionFacade::shouldReceive('log')->once();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_REMOTE_NOT_FOUND, $result->status);
$this->assertStringContainsString('Subscription not found in Stripe', $result->error);
$this->assertEquals(Subscription::REMOTE_RECONCILE_STATUS_NOT_FOUND, $subscription->remote_reconcile_status);
}
public function test_sync_returns_error_on_non_not_found_api_exception()
{
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')
->andThrow(new \Exception('API rate limit exceeded'));
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->uid = 'sub_uid_sync_err';
$subscription->remote_subscription_id = 'sub_rate_limited';
Log::shouldReceive('error')->once();
SubscriptionFacade::shouldReceive('log')->once();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_ERROR, $result->status);
$this->assertStringContainsString('API rate limit exceeded', $result->error);
$this->assertEquals(Subscription::REMOTE_RECONCILE_STATUS_SYNC_FAILED, $subscription->remote_reconcile_status);
}
public function test_sync_detects_period_end_drift()
{
$localPeriodEnd = Carbon::parse('2025-08-01 00:00:00');
$remotePeriodEnd = Carbon::parse('2025-09-01 00:00:00');
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'active',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: $remotePeriodEnd,
currentPeriodStart: Carbon::now(),
canceledAt: null,
latestInvoiceAmount: null,
latestInvoiceStatus: null,
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$mockService->shouldReceive('getRemoteInvoices')->andReturn(['data' => [], 'has_more' => false]);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(false);
$subscription->shouldReceive('isActive')->andReturn(true);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'active';
$subscription->uid = 'sub_uid_period';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = $localPeriodEnd;
$subscription->plan_id = null;
$subscription->remote_metadata = ['remote_plan_id' => 'price_abc'];
SubscriptionFacade::shouldReceive('log')->once();
Log::shouldReceive('info')->once();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_DRIFTED, $result->status);
$this->assertArrayHasKey('current_period_ends_at', $result->changes);
$this->assertEquals($remotePeriodEnd->toDateTimeString(), $result->changes['current_period_ends_at']['new']);
}
public function test_sync_detects_plan_change()
{
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'active',
remotePlanId: 'price_new_plan',
remoteCustomerId: 'cus_test',
currentPeriodEnd: Carbon::now()->addDays(25),
currentPeriodStart: Carbon::now()->subDays(5),
canceledAt: null,
latestInvoiceAmount: null,
latestInvoiceStatus: null,
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$mockService->shouldReceive('getRemoteInvoices')->andReturn(['data' => [], 'has_more' => false]);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(false);
$subscription->shouldReceive('isActive')->andReturn(true);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'active';
$subscription->uid = 'sub_uid_planchange';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = Carbon::now()->addDays(25);
$subscription->plan_id = null;
$subscription->remote_metadata = [
'remote_plan_id' => 'price_old_plan', // differs from remote
];
SubscriptionFacade::shouldReceive('log')->once();
Log::shouldReceive('info')->once();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_DRIFTED, $result->status);
$this->assertArrayHasKey('remote_plan_id', $result->changes);
$this->assertEquals('price_old_plan', $result->changes['remote_plan_id']['old']);
$this->assertEquals('price_new_plan', $result->changes['remote_plan_id']['new']);
}
public function test_sync_detects_invoice_amount_change()
{
$periodEnd = Carbon::now()->addDays(20);
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'active',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: $periodEnd,
currentPeriodStart: Carbon::now()->subDays(10),
canceledAt: null,
latestInvoiceAmount: 49.99, // changed from 29.99
latestInvoiceStatus: 'paid',
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$mockService->shouldReceive('getRemoteInvoices')->andReturn(['data' => [], 'has_more' => false]);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(false);
$subscription->shouldReceive('isActive')->andReturn(true);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'active';
$subscription->uid = 'sub_uid_invoice';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = $periodEnd;
$subscription->plan_id = null;
$subscription->remote_metadata = [
'remote_plan_id' => 'price_abc',
'latest_invoice_amount' => 29.99, // old amount
];
SubscriptionFacade::shouldReceive('log')->once();
Log::shouldReceive('info')->once();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_DRIFTED, $result->status);
$this->assertArrayHasKey('latest_invoice_amount', $result->changes);
$this->assertEquals(29.99, $result->changes['latest_invoice_amount']['old']);
$this->assertEquals(49.99, $result->changes['latest_invoice_amount']['new']);
}
// ==========================================
// syncPlanMapping tests
// ==========================================
public function test_sync_plan_mapping_updated()
{
$remotePlan = new RemotePlanDTO(
id: 'price_abc',
name: 'Updated Plan Name',
price: 39.99,
currency: 'USD',
intervalCount: 1,
intervalUnit: 'month',
status: 'active',
trialDays: 7,
);
$mockGatewayService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockGatewayService->shouldReceive('getRemotePlan')->with('price_abc')->andReturn($remotePlan);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockGatewayService);
$gateway->name = 'Test GW';
$gateway->type = 'stripe-subscription';
$currency = Mockery::mock();
$currency->code = 'USD';
$plan = Mockery::mock(Plan::class)->makePartial();
$plan->price = 39.99;
$plan->frequency_amount = '1';
$plan->frequency_unit = 'month';
$plan->trial_amount = 7;
$plan->trial_unit = 'day';
$plan->name = 'Test Plan';
$plan->shouldReceive('getAttribute')->with('currency')->andReturn($currency);
$plan->shouldReceive('hasTrial')->andReturn(true);
$mapping = Mockery::mock(PlanRemoteMapping::class)->makePartial();
$mapping->shouldReceive('getService')->andReturn($mockGatewayService);
$mapping->shouldReceive('syncFromRemote')->with($remotePlan)->once();
$mapping->shouldReceive('getMismatches')->andReturn([]);
$mapping->shouldReceive('getComparisonSummary')->andReturn(['in_sync' => true]);
$mapping->shouldReceive('getAttribute')->with('plan')->andReturn($plan);
$mapping->shouldReceive('getAttribute')->with('paymentGateway')->andReturn($gateway);
$mapping->uid = 'mapping_uid_1';
$mapping->remote_plan_id = 'price_abc';
$mapping->remote_plan_name = 'Old Plan Name'; // will change
$mapping->remote_price = 29.99; // will change
$mapping->remote_currency = 'USD';
$mapping->remote_interval_count = 1;
$mapping->remote_interval_unit = 'month';
$mapping->remote_trial_days = 0; // will change
$result = $this->service->syncPlanMapping($mapping);
$this->assertEquals('updated', $result['status']);
$this->assertNotEmpty($result['changes']);
$this->assertArrayHasKey('name', $result['changes']);
$this->assertArrayHasKey('price', $result['changes']);
$this->assertArrayHasKey('trial_days', $result['changes']);
}
public function test_sync_plan_mapping_in_sync()
{
$remotePlan = new RemotePlanDTO(
id: 'price_abc',
name: 'Same Name',
price: 29.99,
currency: 'USD',
intervalCount: 1,
intervalUnit: 'month',
status: 'active',
trialDays: 0,
);
$mockGatewayService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockGatewayService->shouldReceive('getRemotePlan')->with('price_abc')->andReturn($remotePlan);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
$gateway->name = 'Test GW';
$gateway->type = 'stripe-subscription';
$currency = Mockery::mock();
$currency->code = 'USD';
$plan = Mockery::mock(Plan::class)->makePartial();
$plan->price = 29.99;
$plan->frequency_amount = '1';
$plan->frequency_unit = 'month';
$plan->trial_amount = null;
$plan->trial_unit = null;
$plan->name = 'Test Plan';
$plan->shouldReceive('getAttribute')->with('currency')->andReturn($currency);
$plan->shouldReceive('hasTrial')->andReturn(false);
$mapping = Mockery::mock(PlanRemoteMapping::class)->makePartial();
$mapping->shouldReceive('getService')->andReturn($mockGatewayService);
$mapping->shouldReceive('syncFromRemote')->once();
$mapping->shouldReceive('getMismatches')->andReturn([]);
$mapping->shouldReceive('getComparisonSummary')->andReturn(['in_sync' => true]);
$mapping->shouldReceive('getAttribute')->with('plan')->andReturn($plan);
$mapping->shouldReceive('getAttribute')->with('paymentGateway')->andReturn($gateway);
$mapping->uid = 'mapping_uid_2';
$mapping->remote_plan_id = 'price_abc';
$mapping->remote_plan_name = 'Same Name';
$mapping->remote_price = 29.99;
$mapping->remote_currency = 'USD';
$mapping->remote_interval_count = 1;
$mapping->remote_interval_unit = 'month';
$mapping->remote_trial_days = 0;
$result = $this->service->syncPlanMapping($mapping);
$this->assertEquals('in_sync', $result['status']);
$this->assertEmpty($result['changes']);
}
public function test_sync_plan_mapping_handles_api_error()
{
$mockGatewayService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockGatewayService->shouldReceive('getRemotePlan')
->andThrow(new \Exception('API rate limit exceeded'));
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
$gateway->name = 'Test GW';
$mapping = Mockery::mock(PlanRemoteMapping::class)->makePartial();
$mapping->shouldReceive('getService')->andReturn($mockGatewayService);
$mapping->shouldReceive('getAttribute')->with('paymentGateway')->andReturn($gateway);
$mapping->uid = 'mapping_uid_3';
$mapping->remote_plan_id = 'price_invalid';
Log::shouldReceive('error')->once();
$result = $this->service->syncPlanMapping($mapping);
$this->assertEquals('error', $result['status']);
$this->assertStringContainsString('API rate limit exceeded', $result['error']);
}
public function test_sync_plan_mapping_warns_on_mismatch_after_sync()
{
$remotePlan = new RemotePlanDTO(
id: 'price_abc',
name: 'Remote Plan',
price: 49.99, // different from local plan's $29.99
currency: 'USD',
intervalCount: 1,
intervalUnit: 'month',
status: 'active',
);
$mockGatewayService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockGatewayService->shouldReceive('getRemotePlan')->andReturn($remotePlan);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
$gateway->name = 'Test GW';
$gateway->type = 'stripe-subscription';
$mismatches = [
['field' => 'price', 'local' => 29.99, 'remote' => 49.99, 'message' => 'Price mismatch: local 29.99 vs remote 49.99'],
];
$mapping = Mockery::mock(PlanRemoteMapping::class)->makePartial();
$mapping->shouldReceive('getService')->andReturn($mockGatewayService);
$mapping->shouldReceive('syncFromRemote')->once();
$mapping->shouldReceive('getMismatches')->andReturn($mismatches);
$mapping->shouldReceive('getComparisonSummary')->andReturn(['in_sync' => false]);
$mapping->shouldReceive('getAttribute')->with('plan')->andReturn((object) ['name' => 'Local Plan']);
$mapping->shouldReceive('getAttribute')->with('paymentGateway')->andReturn($gateway);
$mapping->uid = 'mapping_uid_4';
$mapping->remote_plan_id = 'price_abc';
$mapping->remote_plan_name = 'Remote Plan';
$mapping->remote_price = 49.99; // after sync
$mapping->remote_currency = 'USD';
$mapping->remote_interval_count = 1;
$mapping->remote_interval_unit = 'month';
$mapping->remote_trial_days = 0;
Log::shouldReceive('warning')->once();
$result = $this->service->syncPlanMapping($mapping);
$this->assertNotEmpty($result['mismatches']);
$this->assertEquals('price', $result['mismatches'][0]['field']);
}
// ==========================================
// findGateway tests
// ==========================================
public function test_find_gateway_throws_when_not_found()
{
$this->expectException(\Exception::class);
$this->expectExceptionMessageMatches('/not found/');
$this->service->findGateway('nonexistent_uid');
}
// ==========================================
// DTO integration within sync
// ==========================================
public function test_sync_stores_all_remote_metadata()
{
$periodEnd = Carbon::parse('2025-09-01 00:00:00');
$periodStart = Carbon::parse('2025-08-01 00:00:00');
$canceledAt = null;
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_detailed',
status: 'active',
remotePlanId: 'price_xyz',
remoteCustomerId: 'cus_detailed',
currentPeriodEnd: $periodEnd,
currentPeriodStart: $periodStart,
canceledAt: $canceledAt,
latestInvoiceAmount: 99.99,
latestInvoiceStatus: 'paid',
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe Prod';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isActive')->andReturn(true);
$subscription->shouldReceive('isNew')->andReturn(false);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'active';
$subscription->uid = 'sub_uid_meta';
$subscription->remote_subscription_id = 'sub_detailed';
$subscription->current_period_ends_at = $periodEnd;
$subscription->plan_id = null;
$subscription->remote_metadata = ['remote_plan_id' => 'price_xyz'];
SubscriptionFacade::shouldReceive('log')->once();
$result = $this->service->syncSubscription($subscription);
// Verify metadata was stored
$meta = $subscription->remote_metadata;
$this->assertEquals('active', $meta['remote_status']);
$this->assertEquals('price_xyz', $meta['remote_plan_id']);
$this->assertEquals('cus_detailed', $meta['remote_customer_id']);
$this->assertEquals($periodEnd->toDateTimeString(), $meta['remote_period_end']);
$this->assertEquals($periodStart->toDateTimeString(), $meta['remote_period_start']);
$this->assertEquals(99.99, $meta['latest_invoice_amount']);
$this->assertEquals('paid', $meta['latest_invoice_status']);
$this->assertNotNull($subscription->last_synced_at);
}
// ==========================================
// New status case tests
// ==========================================
public function test_sync_activates_new_subscription_when_remote_active()
{
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'active',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: Carbon::now()->addDays(30),
currentPeriodStart: Carbon::now(),
canceledAt: null,
latestInvoiceAmount: 50.00,
latestInvoiceStatus: 'paid',
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$mockService->shouldReceive('getRemoteInvoices')->andReturn(['data' => [], 'has_more' => false]);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(true);
$subscription->shouldReceive('isActive')->andReturn(false);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'new';
$subscription->uid = 'sub_uid_activate';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = null;
$subscription->plan_id = null;
$subscription->remote_metadata = [];
SubscriptionFacade::shouldReceive('activateFromRemote')->with($subscription, $gateway)->once()->andReturn(true);
SubscriptionFacade::shouldReceive('log')->once();
Log::shouldReceive('info')->zeroOrMoreTimes();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_DRIFTED, $result->status);
$this->assertArrayHasKey('status', $result->changes);
$this->assertEquals('activated_from_sync', $result->changes['status']['new']);
}
public function test_sync_detects_incomplete_expired_for_new_subscription()
{
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'incomplete_expired',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: null,
currentPeriodStart: null,
canceledAt: null,
latestInvoiceAmount: null,
latestInvoiceStatus: null,
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(true);
$subscription->shouldReceive('isActive')->andReturn(false);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'new';
$subscription->uid = 'sub_uid_inc_exp';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = null;
$subscription->plan_id = null;
$subscription->remote_metadata = [];
SubscriptionFacade::shouldReceive('log')->once();
Log::shouldReceive('info')->zeroOrMoreTimes();
Log::shouldReceive('warning')->zeroOrMoreTimes();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_DRIFTED, $result->status);
$this->assertArrayHasKey('status', $result->changes);
$this->assertEquals('incomplete_expired', $result->changes['status']['new']);
$warningTexts = implode(' ', $result->warnings);
$this->assertStringContainsString('expired', $warningTexts);
}
public function test_sync_handles_paused_remote_for_active_local()
{
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'paused',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: Carbon::now()->addDays(10),
currentPeriodStart: Carbon::now()->subDays(20),
canceledAt: null,
latestInvoiceAmount: null,
latestInvoiceStatus: null,
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(false);
$subscription->shouldReceive('isActive')->andReturn(true);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'active';
$subscription->uid = 'sub_uid_paused';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = Carbon::now()->addDays(10);
$subscription->plan_id = null;
$subscription->remote_metadata = [];
SubscriptionFacade::shouldReceive('log')->once();
Log::shouldReceive('info')->zeroOrMoreTimes();
Log::shouldReceive('warning')->zeroOrMoreTimes();
$result = $this->service->syncSubscription($subscription);
$warningTexts = implode(' ', $result->warnings);
$this->assertStringContainsString('paused', $warningTexts);
}
public function test_sync_incomplete_expired_for_active_local()
{
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'incomplete_expired',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: Carbon::now()->addDays(5),
currentPeriodStart: Carbon::now()->subDays(25),
canceledAt: null,
latestInvoiceAmount: null,
latestInvoiceStatus: null,
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(false);
$subscription->shouldReceive('isActive')->andReturn(true);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'active';
$subscription->uid = 'sub_uid_ie_active';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = Carbon::now()->addDays(5);
$subscription->plan_id = null;
$subscription->remote_metadata = [];
SubscriptionFacade::shouldReceive('log')->once();
Log::shouldReceive('info')->zeroOrMoreTimes();
Log::shouldReceive('warning')->zeroOrMoreTimes();
$result = $this->service->syncSubscription($subscription);
$this->assertArrayHasKey('status', $result->changes);
$this->assertEquals('incomplete_expired', $result->changes['status']['new']);
}
// ==========================================
// Cancelled subscription sync tests (Phase 6)
// ==========================================
public function test_sync_cancelled_sub_with_active_remote_reports_reactivated()
{
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'active',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: Carbon::now()->addDays(15),
currentPeriodStart: Carbon::now()->subDays(15),
canceledAt: null,
latestInvoiceAmount: 29.99,
latestInvoiceStatus: 'paid',
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(false);
$subscription->shouldReceive('isActive')->andReturn(false);
$subscription->shouldReceive('isCancelled')->andReturn(true);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'cancelled';
$subscription->uid = 'sub_uid_react';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = Carbon::now()->addDays(15);
$subscription->plan_id = null;
$subscription->remote_metadata = ['remote_plan_id' => 'price_abc', 'latest_invoice_amount' => 29.99];
SubscriptionFacade::shouldReceive('log')->once();
Log::shouldReceive('info')->zeroOrMoreTimes();
Log::shouldReceive('warning')->zeroOrMoreTimes();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_DRIFTED, $result->status);
$this->assertArrayHasKey('status', $result->changes);
$this->assertEquals('reactivated_remote', $result->changes['status']['new']);
$this->assertTrue($result->hasWarnings());
}
public function test_sync_cancelled_sub_with_cancelled_remote_is_in_sync()
{
$periodEnd = Carbon::parse('2025-09-15 00:00:00');
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'canceled',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: $periodEnd->copy(),
currentPeriodStart: Carbon::parse('2025-08-15 00:00:00'),
canceledAt: Carbon::parse('2025-09-10 00:00:00'),
latestInvoiceAmount: 29.99,
latestInvoiceStatus: 'paid',
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(false);
$subscription->shouldReceive('isActive')->andReturn(false);
$subscription->shouldReceive('isCancelled')->andReturn(true);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'cancelled';
$subscription->uid = 'sub_uid_both_cancel';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = $periodEnd->copy();
$subscription->plan_id = null;
$subscription->remote_metadata = ['remote_plan_id' => 'price_abc', 'latest_invoice_amount' => 29.99];
SubscriptionFacade::shouldReceive('log')->once();
$result = $this->service->syncSubscription($subscription);
// Both cancelled, no period drift → in_sync
$this->assertEquals(RemoteSyncResult::STATUS_IN_SYNC, $result->status);
$this->assertEmpty($result->changes);
}
public function test_sync_new_sub_with_active_remote_activates()
{
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_new_act',
status: 'active',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: Carbon::now()->addDays(30),
currentPeriodStart: Carbon::now(),
canceledAt: null,
latestInvoiceAmount: 29.99,
latestInvoiceStatus: 'paid',
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$mockService->shouldReceive('getRemoteInvoices')->andReturn(['data' => [], 'has_more' => false]);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(true);
$subscription->shouldReceive('isActive')->andReturn(false);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'new';
$subscription->uid = 'sub_uid_new_act';
$subscription->remote_subscription_id = 'sub_new_act';
$subscription->current_period_ends_at = null;
$subscription->plan_id = null;
$subscription->remote_metadata = [];
SubscriptionFacade::shouldReceive('activateFromRemote')->with($subscription, $gateway)->once()->andReturn(true);
SubscriptionFacade::shouldReceive('log')->once();
Log::shouldReceive('info')->zeroOrMoreTimes();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_DRIFTED, $result->status);
$this->assertArrayHasKey('status', $result->changes);
$this->assertEquals('activated_from_sync', $result->changes['status']['new']);
}
public function test_sync_new_sub_with_cancelled_remote_warns()
{
$remoteDTO = new RemoteSubscriptionDTO(
id: 'sub_test',
status: 'canceled',
remotePlanId: 'price_abc',
remoteCustomerId: 'cus_test',
currentPeriodEnd: Carbon::now()->addDays(5),
currentPeriodStart: Carbon::now()->subDays(25),
canceledAt: Carbon::now()->subDays(1),
latestInvoiceAmount: null,
latestInvoiceStatus: null,
);
$mockService = Mockery::mock(IntentGatewayInterface::class, RemoteSubscriptionGatewayInterface::class);
$mockService->shouldReceive('getRemoteSubscription')->andReturn($remoteDTO);
$gateway = Mockery::mock(PaymentGateway::class)->makePartial();
Billing::shouldReceive('resolveService')->andReturn($mockService);
$gateway->id = 1;
$gateway->name = 'Stripe GW';
$gateway->type = 'stripe-subscription';
$subscription = Mockery::mock(Subscription::class)->makePartial();
$subscription->shouldReceive('hasRemoteSubscription')->andReturn(true);
$subscription->shouldReceive('getAttribute')->with('remoteGateway')->andReturn($gateway);
$subscription->shouldReceive('getAttribute')->with('plan')->andReturn(null);
$subscription->shouldReceive('isNew')->andReturn(true);
$subscription->shouldReceive('isActive')->andReturn(false);
$subscription->shouldReceive('isEnded')->andReturn(false);
$subscription->shouldReceive('save')->once();
$subscription->remoteGateway = $gateway;
$subscription->status = 'new';
$subscription->uid = 'sub_uid_new_canc';
$subscription->remote_subscription_id = 'sub_test';
$subscription->current_period_ends_at = null;
$subscription->plan_id = null;
$subscription->remote_metadata = [];
SubscriptionFacade::shouldReceive('log')->once();
Log::shouldReceive('info')->zeroOrMoreTimes();
Log::shouldReceive('warning')->zeroOrMoreTimes();
$result = $this->service->syncSubscription($subscription);
$this->assertEquals(RemoteSyncResult::STATUS_DRIFTED, $result->status);
$this->assertArrayHasKey('status', $result->changes);
$this->assertEquals('cancelled_remote', $result->changes['status']['new']);
$this->assertTrue($result->hasWarnings());
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}