feat: add IAP server api (#1676)
This commit is contained in:
@@ -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',
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -40,6 +40,6 @@
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
"node_modules",
|
||||
]
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
Generated
+1406
-58
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -13,7 +13,7 @@
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./packages"
|
||||
}
|
||||
"path": "./apps/readest-app"
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user