feat: add IAP server api (#1676)

This commit is contained in:
Huang Xin
2025-07-25 20:44:56 +08:00
committed by GitHub
parent f3e9983742
commit 8974a87168
14 changed files with 2030 additions and 64 deletions
+9
View File
@@ -11,6 +11,7 @@
"start-web": "dotenv -e .env.web -- next start",
"i18n:extract": "i18next-scanner",
"lint": "next lint",
"test": "dotenv -e .env -e .env.test.local vitest",
"tauri": "tauri",
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs",
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
@@ -65,6 +66,7 @@
"@tauri-apps/plugin-shell": "~2.3.0",
"@tauri-apps/plugin-updater": "^2.9.0",
"@zip.js/zip.js": "^2.7.53",
"app-store-server-api": "^0.17.1",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
"cors": "^2.8.5",
@@ -93,12 +95,15 @@
"semver": "^7.7.1",
"stripe": "^18.2.1",
"tinycolor2": "^1.6.0",
"zod": "^4.0.8",
"zustand": "5.0.6"
},
"devDependencies": {
"@next/bundle-analyzer": "^15.4.2",
"@tailwindcss/typography": "^0.5.16",
"@tauri-apps/cli": "2.7.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/node": "^22.10.1",
@@ -108,6 +113,7 @@
"@types/react-window": "^1.8.8",
"@types/semver": "^7.7.0",
"@types/tinycolor2": "^1.4.6",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"cpx2": "^8.0.0",
"daisyui": "^4.12.24",
@@ -115,6 +121,7 @@
"eslint": "^9.16.0",
"eslint-config-next": "15.0.3",
"i18next-scanner": "^4.6.0",
"jsdom": "^26.1.0",
"mkdirp": "^3.0.1",
"node-env-run": "^4.0.2",
"postcss": "^8.4.49",
@@ -123,6 +130,8 @@
"raw-loader": "^4.0.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"wrangler": "^4.21.0"
}
}
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { POST } from '@/app/api/apple/iap-verify/route';
import { NextRequest } from 'next/server';
import { setupSupabaseMocks } from '../helpers/supabase-mock';
vi.mock('@/utils/supabase', () => ({
supabase: {
auth: {
getUser: vi.fn(),
refreshSession: vi.fn(),
},
from: vi.fn(),
},
createSupabaseAdminClient: vi.fn(),
}));
describe('/api/apple/iap-verify', () => {
it('should verify a valid transaction', async () => {
setupSupabaseMocks();
const request = new NextRequest('http://localhost:3000/api/apple/iap-verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
},
body: JSON.stringify({
transactionId: '2000000969168810',
originalTransactionId: '2000000968585424',
}),
});
const response = await POST(request);
const data = await response.json();
console.log('Response:', data);
expect(response.status).toBe(200);
expect(data.purchase).toBeDefined();
});
});
@@ -0,0 +1,109 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { vi } from 'vitest';
export const setupSupabaseMocks = async (
customResponses = {
getUser: null,
select: null,
upsert: null,
update: null,
insert: null,
adminUpsert: null,
adminSelect: {
data: [],
error: null,
},
adminSelectMany: null,
adminUpdate: null,
adminInsert: null,
adminDelete: null,
adminSelectSingle: null,
},
) => {
const { supabase, createSupabaseAdminClient } = await import('@/utils/supabase');
vi.mocked(supabase.auth.getUser).mockResolvedValue(
customResponses.getUser || {
data: {
user: {
id: 'test-user-123',
email: 'test@example.com',
app_metadata: {},
user_metadata: {},
aud: 'test-aud',
created_at: new Date().toISOString(),
},
},
error: null,
},
);
vi.mocked(supabase.from).mockReturnValue({
select: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn().mockResolvedValue(customResponses.select || { data: null, error: null }),
})),
})),
upsert: vi.fn().mockResolvedValue(customResponses.upsert || { data: {}, error: null }),
update: vi.fn(() => ({
eq: vi.fn().mockResolvedValue(customResponses.update || { data: {}, error: null }),
})),
insert: vi.fn().mockResolvedValue(customResponses.insert || { data: {}, error: null }),
} as any);
vi.mocked(createSupabaseAdminClient).mockReturnValue({
from: vi.fn(() => ({
select: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
single: vi.fn().mockResolvedValue({ data: null, error: null }),
order: vi.fn().mockResolvedValue({ data: [], error: null }),
limit: vi.fn().mockResolvedValue({ data: [], error: null }),
})),
single: vi
.fn()
.mockResolvedValue(customResponses.adminSelect || { data: null, error: null }),
limit: vi.fn(() => ({
single: vi
.fn()
.mockResolvedValue(customResponses.adminSelect || { data: null, error: null }),
})),
order: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
})),
neq: vi.fn(() => ({
order: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
})),
gt: vi.fn(() => ({
order: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
})),
in: vi.fn(() => ({
order: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
})),
order: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
limit: vi
.fn()
.mockResolvedValue(customResponses.adminSelectMany || { data: [], error: null }),
})),
upsert: vi.fn().mockResolvedValue(customResponses.adminUpsert || { data: [], error: null }),
update: vi.fn(() => ({
eq: vi.fn().mockResolvedValue(customResponses.adminUpdate || { data: {}, error: null }),
match: vi.fn().mockResolvedValue(customResponses.adminUpdate || { data: {}, error: null }),
})),
insert: vi.fn().mockResolvedValue(customResponses.adminInsert || { data: {}, error: null }),
delete: vi.fn(() => ({
eq: vi.fn().mockResolvedValue(customResponses.adminDelete || { data: {}, error: null }),
match: vi.fn().mockResolvedValue(customResponses.adminDelete || { data: {}, error: null }),
})),
})),
} as any);
};
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { AppleIAPVerifier } from '@/libs/iap/apple/verifier';
const SKIP_IAP_API_TESTS = !process.env['ENABLE_IAP_API_TESTS'];
const REAL_TEST_DATA = {
validSubscription: {
transactionId: '2000000969168810',
originalTransactionId: '2000000968585424',
productId: 'com.bilingify.readest.monthly.plus',
},
expiredSubscription: {
transactionId: '2000000969189989',
originalTransactionId: '2000000969189989',
productId: 'com.bilingify.readest.monthly.plus',
},
refundedTransaction: {
transactionId: '1000000555666777',
originalTransactionId: '1000000555666777',
productId: 'com.bilingify.readest.monthly.plus',
},
};
describe.skipIf(SKIP_IAP_API_TESTS)('Apple IAP Integration Tests', () => {
let verifier: AppleIAPVerifier;
beforeAll(() => {
verifier = new AppleIAPVerifier({
keyId: process.env['APPLE_IAP_KEY_ID']!,
issuerId: process.env['APPLE_IAP_ISSUER_ID']!,
bundleId: process.env['APPLE_IAP_BUNDLE_ID']!,
privateKey: atob(process.env['APPLE_IAP_PRIVATE_KEY_BASE64']!),
environment: 'sandbox',
});
});
describe('Transaction Verification', () => {
it('should verify a real valid transaction', async () => {
const { transactionId, originalTransactionId } = REAL_TEST_DATA.validSubscription;
const result = await verifier.verifyTransaction(originalTransactionId);
expect(result.success).toBe(true);
expect(result.verified).toBe(true);
expect(result.status).toBe('active');
expect(result.transactionId).toBe(transactionId);
expect(result.originalTransactionId).toBe(originalTransactionId);
expect(result.bundleId).toBe(process.env['APPLE_IAP_BUNDLE_ID']);
expect(result.productId).toBeTruthy();
expect(result.purchaseDate).toBeInstanceOf(Date);
}, 10000);
it('should handle expired subscription correctly', async () => {
const { originalTransactionId } = REAL_TEST_DATA.expiredSubscription;
const result = await verifier.verifyTransaction(originalTransactionId);
expect(result.success).toBe(true);
expect(result.verified).toBe(true);
expect(result.status).toBe('expired');
}, 10000);
it('should reject invalid transaction IDs', async () => {
const result = await verifier.verifyTransaction('invalid_transaction_id');
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
}, 10000);
});
});
@@ -0,0 +1,229 @@
import { z } from 'zod';
import { NextResponse } from 'next/server';
import { defaultIAPVerifier } from '@/libs/iap/apple/verifier';
import { createSupabaseAdminClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
const iapVerificationSchema = z.object({
transactionId: z.string().min(1, 'Transaction ID is required'),
originalTransactionId: z.string().min(1, 'Original Transaction ID is required'),
});
const PRODUCT_MAP: Record<string, string> = {
'com.bilingify.readest.monthly.plus': 'Monthly Plus Subscription',
'com.bilingify.readest.yearly.plus': 'Yearly Plus Subscription',
'com.bilingify.readest.monthly.pro': 'Monthly Pro Subscription',
'com.bilingify.readest.yearly.pro': 'Yearly Pro Subscription',
};
const getProductName = (productId: string) => {
return PRODUCT_MAP[productId] || productId;
};
const getProductPlan = (productId: string) => {
if (productId.includes('plus')) {
return 'plus';
} else if (productId.includes('pro')) {
return 'pro';
}
return 'free';
};
interface Purchase {
status: string;
customerEmail: string;
subscriptionId: string;
planName: string;
productId: string;
platform: string;
transactionId: string;
originalTransactionId: string;
purchaseDate?: string;
expiresDate?: string | null;
quantity: number;
environment: string;
bundleId: string;
webOrderLineItemId?: string;
subscriptionGroupIdentifier?: string;
type?: string;
revocationDate?: string | null;
revocationReason?: number | null;
}
async function updateUserSubscription(userId: string, purchase: Purchase) {
try {
const supabase = createSupabaseAdminClient();
const { data, error } = await supabase.from('apple_iap_subscriptions').upsert(
{
user_id: userId,
platform: purchase.platform,
product_id: purchase.productId,
transaction_id: purchase.transactionId,
original_transaction_id: purchase.originalTransactionId,
status: purchase.status === 'active' ? 'active' : 'expired',
purchase_date: purchase.purchaseDate,
expires_date: purchase.expiresDate,
environment: purchase.environment,
bundle_id: purchase.bundleId,
quantity: purchase.quantity || 1,
auto_renew_status: true,
web_order_line_item_id: purchase.webOrderLineItemId,
subscription_group_identifier: purchase.subscriptionGroupIdentifier,
created_at: new Date(),
updated_at: new Date(),
},
{
onConflict: 'user_id,original_transaction_id',
},
);
if (error) {
console.error('Database update error:', error);
throw new Error(`Database update failed: ${error.message}`);
}
const plan = await getProductPlan(purchase.productId);
await supabase
.from('plans')
.update({
plan: ['active', 'trialing'].includes(purchase.status) ? plan : 'free',
status: purchase.status,
})
.eq('id', userId);
return data;
} catch (error) {
console.error('Failed to update user subscription:', error);
throw error;
}
}
export async function POST(request: Request) {
const body = await request.json();
let validatedInput;
try {
validatedInput = iapVerificationSchema.parse(body);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Invalid input data',
purchase: null,
},
{ status: 400 },
);
}
}
const { transactionId, originalTransactionId } = validatedInput!;
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
if (!user || !token) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 403 });
}
try {
const supabase = createSupabaseAdminClient();
const { data: existingSubscription } = await supabase
.from('apple_iap_subscriptions')
.select('*')
.eq('transaction_id', transactionId)
.eq('user_id', user.id)
.single();
console.log('Existing subscription:', existingSubscription);
if (existingSubscription && existingSubscription.status === 'active') {
console.log('Transaction already verified and active');
const purchase = {
status: existingSubscription.status,
customerEmail: user.email,
subscriptionId:
existingSubscription.web_order_line_item_id ||
existingSubscription.original_transaction_id,
planName: await getProductName(existingSubscription.product_id),
productId: existingSubscription.product_id,
platform: existingSubscription.platform,
transactionId: existingSubscription.transaction_id,
originalTransactionId: existingSubscription.original_transaction_id,
purchaseDate: existingSubscription.purchase_date,
expiresDate: existingSubscription.expires_date,
quantity: existingSubscription.quantity,
environment: existingSubscription.environment,
bundleId: existingSubscription.bundle_id,
};
return NextResponse.json({
purchase,
error: null,
});
}
const verificationResult = await defaultIAPVerifier.verifyTransaction(originalTransactionId);
if (!verificationResult.success) {
console.error('Apple verification failed:', verificationResult.error);
return NextResponse.json(
{
error: verificationResult.error || 'Transaction verification failed with Apple',
purchase: null,
},
{ status: 400 },
);
}
const transaction = verificationResult.transaction!;
console.log('Apple verification successful:', {
transactionId: transaction.transactionId,
productId: transaction.productId,
environment: transaction.environment,
});
if (transaction.environment === 'Sandbox' && process.env.NODE_ENV === 'production') {
console.warn('Sandbox transaction in production environment');
}
const purchase = {
status: verificationResult.status!,
customerEmail: user.email!,
subscriptionId: transaction.webOrderLineItemId || transaction.originalTransactionId,
planName: await getProductName(transaction.productId),
productId: transaction.productId,
platform: 'ios',
transactionId: transaction.transactionId,
originalTransactionId: transaction.originalTransactionId,
purchaseDate: verificationResult.purchaseDate?.toISOString(),
expiresDate: verificationResult.expiresDate?.toISOString() || null,
quantity: transaction.quantity,
environment: transaction.environment.toLowerCase(),
bundleId: transaction.bundleId,
webOrderLineItemId: transaction.webOrderLineItemId,
subscriptionGroupIdentifier: transaction.subscriptionGroupIdentifier,
type: transaction.type,
revocationDate: verificationResult.revocationDate?.toISOString() || null,
revocationReason: verificationResult.revocationReason,
};
try {
await updateUserSubscription(user.id, purchase);
} catch (dbError) {
console.error('Database update failed:', dbError);
return NextResponse.json(
{
error: 'Transaction failed to update database. Please contact support.',
purchase: null,
},
{ status: 500 },
);
}
return NextResponse.json({
purchase,
error: null,
});
} catch (error) {
console.error('IAP verification error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 },
);
}
}
@@ -28,6 +28,7 @@ export async function GET() {
currency: price.currency,
interval: price.recurring?.interval,
product: price.product,
productName: product.name,
};
});
@@ -0,0 +1,126 @@
import {
AppStoreServerAPI,
Environment,
JWSTransactionDecodedPayload,
decodeTransaction,
} from 'app-store-server-api';
export interface AppleIAPConfig {
keyId: string;
issuerId: string;
bundleId: string;
privateKey: string;
environment: 'sandbox' | 'production';
}
export interface VerificationResult {
success: boolean;
verified?: boolean;
status?: string;
transaction?: JWSTransactionDecodedPayload;
environment?: string;
bundleId?: string;
productId?: string;
transactionId?: string;
originalTransactionId?: string;
purchaseDate?: Date;
expiresDate?: Date | null;
quantity?: number;
type?: string;
revocationDate?: Date | null;
revocationReason?: number;
webOrderLineItemId?: string;
subscriptionGroupIdentifier?: string;
error?: string;
}
export class AppleIAPVerifier {
private client: AppStoreServerAPI;
private bundleId: string;
private environment: 'sandbox' | 'production';
constructor(config: AppleIAPConfig) {
this.bundleId = config.bundleId;
this.environment = config.environment;
this.client = new AppStoreServerAPI(
config.privateKey,
config.keyId,
config.issuerId,
config.bundleId,
config.environment === 'sandbox' ? Environment.Sandbox : Environment.Production,
);
}
async verifyTransaction(originalTransactionId: string): Promise<VerificationResult> {
let response;
try {
response = await this.client.getSubscriptionStatuses(originalTransactionId);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
if (response.data && response.data.length > 0) {
const transaction = response.data[0]!;
const lastTransaction = transaction.lastTransactions.find(
(item) => item.originalTransactionId === originalTransactionId,
);
if (!lastTransaction) {
return {
success: false,
error: 'Transaction not found',
};
}
const decodedTransaction = await decodeTransaction(lastTransaction.signedTransactionInfo);
const status = lastTransaction.status;
const expiresDate = decodedTransaction.expiresDate
? new Date(decodedTransaction.expiresDate)
: undefined;
const now = new Date();
// Status 1 = Active subscription
const isActive = status === 1 && (!expiresDate || expiresDate > now);
return {
success: true,
verified: true,
status: isActive ? 'active' : 'expired',
transaction: decodedTransaction,
environment: this.environment,
bundleId: this.bundleId,
productId: decodedTransaction.productId,
transactionId: decodedTransaction.transactionId,
originalTransactionId: decodedTransaction.originalTransactionId,
purchaseDate: new Date(decodedTransaction.purchaseDate),
expiresDate: decodedTransaction.expiresDate
? new Date(decodedTransaction.expiresDate)
: null,
quantity: decodedTransaction.quantity,
type: decodedTransaction.type,
revocationDate: decodedTransaction.revocationDate
? new Date(decodedTransaction.revocationDate)
: null,
revocationReason: decodedTransaction.revocationReason,
webOrderLineItemId: decodedTransaction.webOrderLineItemId,
subscriptionGroupIdentifier: decodedTransaction.subscriptionGroupIdentifier,
};
} else {
return {
success: false,
error: 'No transactions found for this original transaction ID',
};
}
}
}
export const createAppleIAPVerifier = (config: AppleIAPConfig) => new AppleIAPVerifier(config);
export const defaultIAPVerifier = new AppleIAPVerifier({
keyId: process.env['APPLE_IAP_KEY_ID']!,
issuerId: process.env['APPLE_IAP_ISSUER_ID']!,
bundleId: process.env['APPLE_IAP_BUNDLE_ID']!,
privateKey: atob(process.env['APPLE_IAP_PRIVATE_KEY_BASE64']! || ''),
environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});
+1 -3
View File
@@ -45,7 +45,7 @@ interface RestorePurchasesResponse {
purchases: IAPPurchase[];
}
class IAPService {
export class IAPService {
async initialize(): Promise<boolean> {
const result = await invoke<InitializeResponse>('plugin:native-bridge|iap_initialize', {
payload: {} as InitializeRequest,
@@ -95,5 +95,3 @@ class IAPService {
}
}
}
export default IAPService;
+1 -1
View File
@@ -40,6 +40,6 @@
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
"node_modules",
]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
},
});
+21
View File
@@ -0,0 +1,21 @@
class ESBuildAndJSDOMCompatibleTextEncoder extends TextEncoder {
constructor() {
super();
}
override encode(input: string) {
if (typeof input !== 'string') {
throw new TypeError('`input` must be a string');
}
const decodedURI = decodeURIComponent(encodeURIComponent(input));
const arr = new Uint8Array(decodedURI.length);
const chars = decodedURI.split('');
for (let i = 0; i < chars.length; i++) {
arr[i] = decodedURI[i]!.charCodeAt(0);
}
return arr;
}
}
global.TextEncoder = ESBuildAndJSDOMCompatibleTextEncoder;
+4
View File
@@ -3,6 +3,10 @@ main = ".open-next/worker.js"
compatibility_date = "2025-02-04"
compatibility_flags = ["nodejs_compat"]
[observability]
enabled = true
head_sampling_rate = 1
[assets]
directory = ".open-next/assets"
binding = "ASSETS"
+1406 -58
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -13,7 +13,7 @@
"files": [],
"references": [
{
"path": "./packages"
}
"path": "./apps/readest-app"
},
]
}