Hard paywall

This commit is contained in:
2026-04-28 20:35:53 +02:00
parent 05efbb9910
commit 86631a9bc0
15 changed files with 15251 additions and 14164 deletions

View File

@@ -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);
});
});