diff --git a/src/iap_refresh_management.js b/src/iap_refresh_management.js new file mode 100644 index 0000000..042185f --- /dev/null +++ b/src/iap_refresh_management.js @@ -0,0 +1,64 @@ +const { get_account_and_user_id, get_user_uuid, add_successful_transactions_to_account, mark_iap_history_was_refreshed } = require('./user_management'); +const { verify_transaction_id } = require('./app_store_receipt_verifier'); +const { current_time } = require('./utils'); + +const IAP_REFRESH_PERIOD = 60 * 60 * 24; // 24 hours + +/** + * Checks an account to see if it needs an update with Apple's IAP servers, and updates it if needed. + * + * @param {Object} app - The app object + * @param {string} pubkey - The public key of the user, hex encoded + * @returns {Promise<{account?: Object, user_id?: number, request_error?: string | null}>} - The account object, the user ID, and the request error, if any + */ +async function update_iap_history_with_apple_if_needed_and_return_updated_user(app, pubkey) { + let { account, user_id } = get_account_and_user_id(app, pubkey); + if (!account) { + // Account not found + return { account: null, user_id: null, request_error: null }; + } + + if (!should_iap_transaction_history_be_refreshed(account)) { + // No need to refresh iap history + return { account: account, user_id: user_id, request_error: null }; + } + + // Refresh the iap history + const account_uuid = get_user_uuid(app, account.pubkey); + const last_transaction = account.transactions[account.transactions.length - 1]; + try { + let verified_transaction_history = await verify_transaction_id(last_transaction.id, account_uuid); + mark_iap_history_was_refreshed(app, account.pubkey); + if (!verified_transaction_history) { + return { account: account, user_id: user_id }; + } + + const { account: new_account, user_id: latest_user_id, request_error } = add_successful_transactions_to_account(app, account.pubkey, verified_transaction_history); + if (request_error) { + return { account: account, user_id: user_id, request_error: request_error }; + } + return { account: new_account, user_id: latest_user_id } + } catch (error) { + return { account: account, user_id: user_id, request_error: error.message }; + } +} + +async function should_iap_transaction_history_be_refreshed(account) { + const account_active = (account.expiry && current_time() < account.expiry) ? true : false; + const last_transaction = account.transactions[account.transactions.length - 1]; + if (account_active || last_transaction == undefined || last_transaction.type != "iap") { + // No need to update iap history because account is either active, or the last transaction was not an IAP transaction + return false; + } + + if (account.last_iap_history_refresh && (current_time() - account.last_iap_history_refresh) < IAP_REFRESH_PERIOD) { + // We already checked with Apple in the last 24 hours. No need to check again for now. + return false; + } + + // If the account is inactive and the last transaction was an IAP, we should check with Apple with it was renewed. + return true; +} + +module.exports = { update_iap_history_with_apple_if_needed_and_return_updated_user, should_iap_transaction_history_be_refreshed }; + diff --git a/src/router_config.js b/src/router_config.js index d6bbc02..bcb7f89 100644 --- a/src/router_config.js +++ b/src/router_config.js @@ -8,6 +8,7 @@ const { required_nip98_auth, capture_raw_body, optional_nip98_auth } = require(' const { nip19 } = require('nostr-tools') const { PURPLE_ONE_MONTH } = require('./invoicing') const error = require("debug")("api:error") +const { update_iap_history_with_apple_if_needed_and_return_updated_user } = require('./iap_refresh_management') function config_router(app) { const router = app.router @@ -36,13 +37,18 @@ function config_router(app) { // MARK: Account management routes - router.get('/accounts/:pubkey', (req, res) => { + router.get('/accounts/:pubkey', async (req, res) => { const id = req.params.pubkey if (!id) { error_response(res, 'Could not parse account id') return } - let { account, user_id } = get_account_and_user_id(app, id) + let { account, user_id, request_error } = await update_iap_history_with_apple_if_needed_and_return_updated_user(app, id) + + if (request_error) { + // Log the error, but continue with the request + error("Error when updating IAP history: %s", request_error) + } if (!account) { simple_response(res, 404) diff --git a/src/user_management.js b/src/user_management.js index 1f172d5..1c95d89 100644 --- a/src/user_management.js +++ b/src/user_management.js @@ -137,6 +137,21 @@ function add_successful_transactions_to_account(api, pubkey, transactions) { return { account: new_account, user_id, request_error: null } } +/** Records that iap history was refreshed +* @param {Object} api - The API object +* @param {string} pubkey - The public key of the user, hex encoded +* @returns {{account?: Object, request_error?: string | null, user_id?: number}} - The account object, or null if the account does not exist, and the request error, or null if there was no error +*/ +function mark_iap_history_was_refreshed(api, pubkey) { + const account = get_account(api, pubkey) + if (!account) { + return { request_error: 'Account not found' } + } + account.last_iap_history_refresh = current_time() + put_account(api, pubkey, account) + return { account: account, request_error: null } +} + function get_account_info_payload(subscriber_number, account, authenticated = false) { if (!account) return null @@ -183,4 +198,4 @@ function delete_account(api, pubkey) { return { delete_error: null }; } -module.exports = { check_account, create_account, get_account_info_payload, get_account, put_account, get_account_and_user_id, get_user_id_from_pubkey, get_user_uuid, delete_account, add_successful_transactions_to_account } +module.exports = { check_account, create_account, get_account_info_payload, get_account, put_account, get_account_and_user_id, get_user_id_from_pubkey, get_user_uuid, delete_account, add_successful_transactions_to_account, mark_iap_history_was_refreshed } diff --git a/test/iap_flow.test.js b/test/iap_flow.test.js index 33082e2..3c86897 100644 --- a/test/iap_flow.test.js +++ b/test/iap_flow.test.js @@ -175,3 +175,72 @@ test('IAP Flow — Repeated receipts', async (t) => { t.end(); }); + +// This flow is necessary in the following cases: +// - Subscriber uses TestFlight version which is not connected to the production IAP environment +// - Subscriber does not use the iOS app for several weeks and the receipt is no longer available from the local StoreKit API +// - Other conditions that may prevent the receipt/transaction ID from being sent from the user's device to the server. +test('IAP Flow — server to server renewal flow (no receipt sent from user device)', async (t) => { + // Initialize the PurpleTestController and a client + const purple_api_controller = await PurpleTestController.new(t); + const user_pubkey_1 = purple_api_controller.new_client(); + + // Set the current time to the time of the purchase, and associate the pubkey with the user_uuid on the server + const user_uuid = MOCK_ACCOUNT_UUIDS[0] + purple_api_controller.set_account_uuid(user_pubkey_1, user_uuid); // Associate the pubkey with the user_uuid on the server + const entire_decoded_tx_history = purple_api_controller.iap.get_decoded_transaction_history(user_uuid) + if (entire_decoded_tx_history.length != 2) { + t.fail('Expected 2 transactions in the decoded transaction history. The test assumption is broken and it needs to be updated.') + t.end() + return + } + let [tx_1, tx_2] = entire_decoded_tx_history + purple_api_controller.set_current_time(tx_1.purchaseDate/1000); // Set the current time to the time of the first purchase + + // Edit transaction history on our mock IAP controller so that it contains only one transaction for now. + // We will re-add the second transaction later to simulate the renewal. + const transaction_id = purple_api_controller.iap.get_transaction_id(user_uuid); + let encoded_transaction_history = purple_api_controller.iap.get_transaction_history(transaction_id); + purple_api_controller.iap.set_transaction_history(transaction_id, [encoded_transaction_history[0]]); + + // Try to get the account info + const response = await purple_api_controller.clients[user_pubkey_1].get_account(); + t.same(response.statusCode, 404); // Account should not exist yet + + // Simulate first IAP purchase on the iOS side + // Send the receipt to the server to activate the account + const iap_response = await purple_api_controller.clients[user_pubkey_1].send_transaction_id(user_uuid, transaction_id); + t.same(iap_response.statusCode, 200); + + // Read the account info and check that the account is active + const account_info_response_1 = await purple_api_controller.clients[user_pubkey_1].get_account(); + t.same(account_info_response_1.statusCode, 200); + t.same(account_info_response_1.body.active, true); + + // Let's move the clock forward to just before the renewal date + purple_api_controller.set_current_time(account_info_response_1.body.expiry - 60 * 1); // 1 minute before expiry + + // Read the account info again and check that the account is still active + const account_info_response_2 = await purple_api_controller.clients[user_pubkey_1].get_account(); + t.same(account_info_response_2.statusCode, 200); + t.same(account_info_response_2.body.active, true); + + // Let's move the clock forward to the purchase date of the 2nd transaction + purple_api_controller.set_current_time(tx_2.purchaseDate/1000); + + // Now, let's add the 2nd transaction to the history again, to simulate that a renewal has occurred on the Apple server. + purple_api_controller.iap.set_transaction_history(transaction_id, encoded_transaction_history); + + // "NO-OP" - The user does not send the receipt to the server this time + + // Move the clock forward 1 minute + purple_api_controller.set_current_time(tx_2.purchaseDate/1000 + 60 * 1); + + // Read the account info again and check that the account is active + const account_info_response_3 = await purple_api_controller.clients[user_pubkey_1].get_account(); + t.same(account_info_response_3.statusCode, 200); + t.same(account_info_response_3.body.expiry, tx_2.expiresDate/1000); + t.same(account_info_response_3.body.active, true); + + t.end(); +}); diff --git a/test/router_config.test.js b/test/router_config.test.js index 74af66f..d06548d 100644 --- a/test/router_config.test.js +++ b/test/router_config.test.js @@ -4,6 +4,7 @@ const config_router = require('../src/router_config.js').config_router; const nostr = require('nostr'); const current_time = require('../src/utils.js').current_time; const { supertest_client } = require('./controllers/utils.js'); +const { v4: uuidv4 } = require('uuid') test('config_router - Account management routes', async (t) => { const account_info = { @@ -48,6 +49,17 @@ test('config_router - Account management routes', async (t) => { } return Object.keys(pubkeys_to_user_ids) } + }, + pubkeys_to_user_uuids: { + get: (pubkey) => { + return uuidv4() + }, + put: (pubkey, user_uuid) => { + return + }, + getKeys: (options) => { + return Object.keys(pubkeys_to_user_ids) + } } },