Hard paywall
This commit is contained in:
@@ -68,77 +68,94 @@ describe('mockBackendService billing simulation', () => {
|
||||
productId: 'topup_small',
|
||||
});
|
||||
|
||||
expect(first.billing.credits.topupBalance).toBe(25);
|
||||
expect(second.billing.credits.topupBalance).toBe(25);
|
||||
expect(first.billing.credits.topupBalance).toBe(30);
|
||||
expect(second.billing.credits.topupBalance).toBe(30);
|
||||
});
|
||||
|
||||
it('consumes plan credits before topup credits', async () => {
|
||||
const userId = 'test-user-credit-order';
|
||||
await mockBackendService.simulatePurchase({
|
||||
userId,
|
||||
idempotencyKey: 'topup-order-1',
|
||||
it('consumes plan credits before topup credits', async () => {
|
||||
const userId = 'test-user-credit-order';
|
||||
await mockBackendService.simulatePurchase({
|
||||
userId,
|
||||
idempotencyKey: 'sub-order-1',
|
||||
productId: 'monthly_pro',
|
||||
});
|
||||
await mockBackendService.simulatePurchase({
|
||||
userId,
|
||||
idempotencyKey: 'topup-order-1',
|
||||
productId: 'topup_small',
|
||||
});
|
||||
|
||||
let lastScan = await runScan(userId, 'scan-order-0');
|
||||
expect(lastScan.billing.credits.usedThisCycle).toBe(1);
|
||||
expect(lastScan.billing.credits.topupBalance).toBe(25);
|
||||
|
||||
for (let i = 1; i <= 15; i += 1) {
|
||||
lastScan = await runScan(userId, `scan-order-${i}`);
|
||||
}
|
||||
|
||||
expect(lastScan.billing.credits.usedThisCycle).toBe(15);
|
||||
expect(lastScan.billing.credits.topupBalance).toBe(24);
|
||||
});
|
||||
|
||||
it('can deplete all available credits via webhook simulation', async () => {
|
||||
const userId = 'test-user-deplete-credits';
|
||||
await mockBackendService.simulatePurchase({
|
||||
userId,
|
||||
idempotencyKey: 'topup-deplete-1',
|
||||
productId: 'topup_small',
|
||||
});
|
||||
|
||||
const response = await mockBackendService.simulateWebhook({
|
||||
userId,
|
||||
idempotencyKey: 'webhook-deplete-1',
|
||||
event: 'credits_depleted',
|
||||
});
|
||||
|
||||
expect(response.billing.credits.available).toBe(0);
|
||||
expect(response.billing.credits.topupBalance).toBe(0);
|
||||
expect(response.billing.credits.usedThisCycle).toBe(response.billing.credits.monthlyAllowance);
|
||||
});
|
||||
|
||||
expect(lastScan.billing.credits.usedThisCycle).toBe(1);
|
||||
expect(lastScan.billing.credits.topupBalance).toBe(30);
|
||||
|
||||
let scanIndex = 1;
|
||||
while (
|
||||
lastScan.billing.credits.usedThisCycle < 100
|
||||
&& lastScan.billing.credits.topupBalance === 30
|
||||
&& scanIndex < 150
|
||||
) {
|
||||
lastScan = await runScan(userId, `scan-order-${scanIndex}`);
|
||||
scanIndex += 1;
|
||||
}
|
||||
|
||||
if (lastScan.billing.credits.topupBalance === 30) {
|
||||
lastScan = await runScan(userId, `scan-order-${scanIndex}`);
|
||||
}
|
||||
|
||||
expect(lastScan.billing.credits.usedThisCycle).toBe(100);
|
||||
expect(lastScan.billing.credits.topupBalance).toBeLessThan(30);
|
||||
expect(lastScan.billing.credits.topupBalance).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('can deplete all available credits via webhook simulation', async () => {
|
||||
const userId = 'test-user-deplete-credits';
|
||||
await mockBackendService.simulatePurchase({
|
||||
userId,
|
||||
idempotencyKey: 'topup-deplete-1',
|
||||
productId: 'topup_small',
|
||||
});
|
||||
|
||||
const response = await mockBackendService.simulateWebhook({
|
||||
userId,
|
||||
idempotencyKey: 'webhook-deplete-1',
|
||||
event: 'credits_depleted',
|
||||
});
|
||||
|
||||
expect(response.billing.credits.available).toBe(0);
|
||||
expect(response.billing.credits.topupBalance).toBe(0);
|
||||
expect(response.billing.credits.usedThisCycle).toBe(response.billing.credits.monthlyAllowance);
|
||||
});
|
||||
|
||||
it('does not double-charge scan when idempotency key is reused', async () => {
|
||||
const userId = 'test-user-scan-idempotency';
|
||||
await mockBackendService.simulatePurchase({
|
||||
userId,
|
||||
idempotencyKey: 'sub-scan-idempotency',
|
||||
productId: 'monthly_pro',
|
||||
});
|
||||
const first = await runScan(userId, 'scan-abc');
|
||||
const second = await runScan(userId, 'scan-abc');
|
||||
|
||||
expect(first.creditsCharged).toBe(1);
|
||||
expect(second.creditsCharged).toBe(1);
|
||||
expect(second.billing.credits.available).toBe(first.billing.credits.available);
|
||||
});
|
||||
|
||||
it('enforces free monthly credit limit', async () => {
|
||||
const userId = 'test-user-credit-limit';
|
||||
let successfulScans = 0;
|
||||
let errorCode: string | null = null;
|
||||
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
try {
|
||||
await runScan(userId, `scan-${i}`);
|
||||
successfulScans += 1;
|
||||
} catch (error) {
|
||||
errorCode = (error as { code?: string }).code || null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
expect(first.creditsCharged).toBeGreaterThan(0);
|
||||
expect(second.creditsCharged).toBe(first.creditsCharged);
|
||||
expect(second.billing.credits.available).toBe(first.billing.credits.available);
|
||||
});
|
||||
|
||||
it('blocks free users from real scans', async () => {
|
||||
const userId = 'test-user-credit-limit';
|
||||
let successfulScans = 0;
|
||||
let errorCode: string | null = null;
|
||||
|
||||
try {
|
||||
await runScan(userId, 'scan-free-hard-paywall');
|
||||
successfulScans += 1;
|
||||
} catch (error) {
|
||||
errorCode = (error as { code?: string }).code || null;
|
||||
}
|
||||
|
||||
expect(errorCode).toBe('INSUFFICIENT_CREDITS');
|
||||
expect(successfulScans).toBeGreaterThanOrEqual(7);
|
||||
expect(successfulScans).toBeLessThanOrEqual(15);
|
||||
expect(successfulScans).toBe(0);
|
||||
});
|
||||
|
||||
it('syncs pro entitlement from RevenueCat customer info', async () => {
|
||||
@@ -162,6 +179,70 @@ describe('mockBackendService billing simulation', () => {
|
||||
expect(response.billing.entitlement.renewsAt).toBe('2026-04-30T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('limits RevenueCat trial entitlement to trial credits', async () => {
|
||||
const response = await mockBackendService.syncRevenueCatState({
|
||||
userId: 'test-user-rc-trial',
|
||||
customerInfo: {
|
||||
entitlements: {
|
||||
active: {
|
||||
pro: {
|
||||
productIdentifier: 'monthly_pro',
|
||||
expirationDate: '2026-04-30T00:00:00.000Z',
|
||||
periodType: 'TRIAL',
|
||||
},
|
||||
},
|
||||
},
|
||||
nonSubscriptions: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.billing.entitlement.plan).toBe('pro');
|
||||
expect(response.billing.credits.monthlyAllowance).toBe(30);
|
||||
expect(response.billing.credits.available).toBe(30);
|
||||
});
|
||||
|
||||
it('resets trial usage when RevenueCat trial converts to paid pro', async () => {
|
||||
const userId = 'test-user-rc-trial-converts';
|
||||
await mockBackendService.syncRevenueCatState({
|
||||
userId,
|
||||
customerInfo: {
|
||||
entitlements: {
|
||||
active: {
|
||||
pro: {
|
||||
productIdentifier: 'monthly_pro',
|
||||
expirationDate: '2026-04-30T00:00:00.000Z',
|
||||
periodType: 'TRIAL',
|
||||
},
|
||||
},
|
||||
},
|
||||
nonSubscriptions: {},
|
||||
},
|
||||
});
|
||||
|
||||
const trialScan = await runScan(userId, 'trial-conversion-scan');
|
||||
expect(trialScan.billing.credits.usedThisCycle).toBeGreaterThan(0);
|
||||
|
||||
const paidResponse = await mockBackendService.syncRevenueCatState({
|
||||
userId,
|
||||
customerInfo: {
|
||||
entitlements: {
|
||||
active: {
|
||||
pro: {
|
||||
productIdentifier: 'monthly_pro',
|
||||
expirationDate: '2026-05-30T00:00:00.000Z',
|
||||
periodType: 'NORMAL',
|
||||
},
|
||||
},
|
||||
},
|
||||
nonSubscriptions: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(paidResponse.billing.credits.monthlyAllowance).toBe(100);
|
||||
expect(paidResponse.billing.credits.usedThisCycle).toBe(0);
|
||||
expect(paidResponse.billing.credits.available).toBe(100);
|
||||
});
|
||||
|
||||
it('credits RevenueCat top-up transactions only once', async () => {
|
||||
const userId = 'test-user-rc-topup';
|
||||
await mockBackendService.syncRevenueCatState({
|
||||
@@ -194,7 +275,7 @@ describe('mockBackendService billing simulation', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(second.billing.credits.topupBalance).toBe(25);
|
||||
expect(second.billing.credits.topupBalance).toBe(30);
|
||||
});
|
||||
|
||||
it('ignores malformed pro entitlements coming from top-up customer info', async () => {
|
||||
@@ -223,8 +304,8 @@ describe('mockBackendService billing simulation', () => {
|
||||
|
||||
expect(response.billing.entitlement.plan).toBe('free');
|
||||
expect(response.billing.entitlement.status).toBe('inactive');
|
||||
expect(response.billing.credits.topupBalance).toBe(25);
|
||||
expect(response.billing.credits.available).toBe(40);
|
||||
expect(response.billing.credits.topupBalance).toBe(30);
|
||||
expect(response.billing.credits.available).toBe(0);
|
||||
});
|
||||
|
||||
it('does not downgrade an existing pro user during a top-up sync', async () => {
|
||||
@@ -270,7 +351,7 @@ describe('mockBackendService billing simulation', () => {
|
||||
});
|
||||
|
||||
expect(response.billing.entitlement.plan).toBe('pro');
|
||||
expect(response.billing.credits.available).toBe(275);
|
||||
expect(response.billing.credits.topupBalance).toBe(25);
|
||||
expect(response.billing.credits.available).toBe(130);
|
||||
expect(response.billing.credits.topupBalance).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user