Skip to content

Commit

Permalink
Apple In-app purchase (IAP) automatic server-to-server renewal
Browse files Browse the repository at this point in the history
This commit fixes the issue where some users are not able to get their
Purple membership renewed when they are on TestFlight.

It does so by implementing a lazy checker that tries to refresh a user's IAP transaction history with Apple's server.

The automatic lazy refresh occurs when anyone fetches information about the respective account and:
- The account's last transaction is an Apple In-app purchase
- The account is not active
- IAP history for this account has not been checked for more than 24 hours (to avoid spamming Apple's servers)

It also adds automated test coverage around this new functionality.

------
Testing
------

- Passing all pre-existing automated tests (.env independent)
- Passing the new automated tests
- Performed type checks and found no type-check regressions
- Passed manual test below

----------
Manual test
----------

PASS

Setup:
- Purple server running locally
- Purple server with environment correctly setup and connected to IAP sandbox environment
- iPhone connected to the local purple server (In developer settings)
- iPhone running local build built under the "Release" scheme to connect to IAP sandbox environment
- Sandbox App Store account setup and purchase history cleared
- Local Purple database cleared
- Renewal period set to 5 minutes in Sandbox setting
- Second simulator device running on a separate account and also connected to the same local purple server
iOS: 17.5
Device: iPhone 13 mini (real device)
damus-api: This commit
Damus: 1.10 fd130b78e748c7040989f40969c99017256c9418
Steps:
1. Buy Purple through the Sandbox
2. Check account status is as expected
3. Run the database dump script
4. Check the dumped data. There should be one IAP transaction on the database.
5. Check the expiry date (should be 5 minutes from now)
6. Completely close the Damus app
6. Wait for that expiry date
7. Use damus through a second device
8. Visit the profile through this second device.
9. Account should appear with the damus star
10. Dump the local database again
11. The second transaction should have appeared on the tx history, even with no involvement from the primary device
12. Check the new expiry date.
13. Cancel subscription from the primary device
14. Close Damus on all devices
15. Wait for new expiry date
16. Access that profile again from the second device
17. The Damus star should no longer be present.

Changelog-Added: Apple In-app purchase automatic server-to-server renewal
Closes: #6
Signed-off-by: Daniel D’Aquino <[email protected]>
Reviewed-by: William Casarin <[email protected]>
  • Loading branch information
danieldaquino committed Jul 8, 2024
1 parent 9b1b63d commit 2620ad8
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 3 deletions.
64 changes: 64 additions & 0 deletions src/iap_refresh_management.js
Original file line number Diff line number Diff line change
@@ -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 };

10 changes: 8 additions & 2 deletions src/router_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 16 additions & 1 deletion src/user_management.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
69 changes: 69 additions & 0 deletions test/iap_flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
12 changes: 12 additions & 0 deletions test/router_config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
}
}

},
Expand Down

0 comments on commit 2620ad8

Please sign in to comment.