Skip to content

Commit

Permalink
Add Order ID lookup functions to aid customer support
Browse files Browse the repository at this point in the history
This commit adds lookup functions that allows transaction histories to
be found using the Order ID from the customer. The Order ID is a string
that gets included in each email receipt and can be used in customer
support situations.

Currently the function is not exposed in any external APIs. It can only
be used interactively on a Node.js console.

From the perspective of the API and the server functionality, this commit should be a no-op.

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

Damus API: This commit
Coverage:
- All automated tests are passing (with no `.env`)
- Typescript type check has no regressions
- New function was tested by using it via Node.js console to help solve a real customer support request

Closes: #9
Changelog-Added: Add Order ID lookup functions to aid customer support
Signed-off-by: Daniel D’Aquino <[email protected]>
Reviewed-by: William Casarin <[email protected]>
  • Loading branch information
danieldaquino committed Jul 3, 2024
1 parent 7cef4da commit 790210d
Showing 1 changed file with 99 additions and 28 deletions.
127 changes: 99 additions & 28 deletions src/app_store_receipt_verifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ const { current_time } = require("./utils")
const fs = require('fs')
const debug = require('debug')('api:iap')

const DEFAULT_ROOT_CA_DIR = './apple-root-ca'
/**
* Mock transaction history for testing purposes.
* @type {Transaction[]}
*/
const MOCK_TRANSACTION_HISTORY = [{
type: "iap",
id: "1",
start_date: current_time(),
end_date: current_time() + 60 * 60 * 24 * 30,
purchased_date: current_time(),
duration: null
}];

/**
* Verifies the receipt data and returns the expiry date if the receipt is valid.
*
Expand All @@ -25,18 +39,11 @@ async function verify_receipt(receipt_data, authenticated_account_token) {
// Mocking logic for testing purposes
if (process.env.MOCK_VERIFY_RECEIPT == "true") {
debug("Mocking verify_receipt with expiry date 30 days from now");
return [{
type: "iap",
id: "1",
start_date: current_time(),
end_date: current_time() + 60 * 60 * 24 * 30,
purchased_date: current_time(),
duration: null
}]
return MOCK_TRANSACTION_HISTORY;
}

// Setup the environment and client
const rootCaDir = process.env.IAP_ROOT_CA_DIR || './apple-root-ca'
const rootCaDir = process.env.IAP_ROOT_CA_DIR || DEFAULT_ROOT_CA_DIR;
const bundleId = process.env.IAP_BUNDLE_ID;
const environment = getAppStoreEnvironmentFromEnv();
const client = createAppStoreServerAPIClientFromEnv();
Expand Down Expand Up @@ -65,18 +72,11 @@ async function verify_transaction_id(transaction_id, authenticated_account_token
// Mocking logic for testing purposes
if (process.env.MOCK_VERIFY_RECEIPT == "true") {
debug("Mocking verify_receipt with expiry date 30 days from now");
return [{
type: "iap",
id: "1",
start_date: current_time(),
end_date: current_time() + 60 * 60 * 24 * 30,
purchased_date: current_time(),
duration: null
}];
return MOCK_TRANSACTION_HISTORY;
}

// Setup the environment and client
const rootCaDir = process.env.IAP_ROOT_CA_DIR || './apple-root-ca'
const rootCaDir = process.env.IAP_ROOT_CA_DIR || DEFAULT_ROOT_CA_DIR;
const bundleId = process.env.IAP_BUNDLE_ID;
const environment = getAppStoreEnvironmentFromEnv();
const client = createAppStoreServerAPIClientFromEnv();
Expand All @@ -89,6 +89,35 @@ async function verify_transaction_id(transaction_id, authenticated_account_token
}


/**
* Looks up the order id and returns the verified transaction history if valid
*
* @param {string} order_id - The order id to lookup
*
* @returns {Promise<Transaction[]|null>} The validated transactions
*/
async function lookup_order_id(order_id) {
debug("Looking up order id '%d'", order_id);
// Mocking logic for testing purposes
if (process.env.MOCK_VERIFY_RECEIPT == "true") {
debug("Mocking verify_receipt with expiry date 30 days from now");
return MOCK_TRANSACTION_HISTORY;
}

// Setup the environment and client
const rootCaDir = process.env.IAP_ROOT_CA_DIR || DEFAULT_ROOT_CA_DIR;
const bundleId = process.env.IAP_BUNDLE_ID;
const environment = getAppStoreEnvironmentFromEnv();
const client = createAppStoreServerAPIClientFromEnv();

// If the transaction ID is present, fetch the transaction history, verify the transactions, and return the latest expiry date
if (order_id != null) {
return await fetchValidatedTransactionsFromOrderId(client, order_id, rootCaDir, environment, bundleId, undefined);
}
return Promise.resolve(null);
}


/**
* Fetches transaction history with the App Store API, verifies the transactions, and returns formatted transactions.
* It also verifies if the transaction belongs to the account who made the request.
Expand All @@ -114,17 +143,59 @@ async function fetchValidatedTransactions(client, transactionId, rootCaDir, envi
return null;
}
return validDecodedTransactions.map((decodedTransaction) => {
return {
type: "iap",
id: decodedTransaction.transactionId,
start_date: decodedTransaction.purchaseDate / 1000,
end_date: decodedTransaction.expiresDate / 1000,
purchased_date: decodedTransaction.purchaseDate / 1000,
duration: null
}
return transformDecodedTransactionToTransaction(decodedTransaction)
})
}

/**
* Fetches transaction history associated with an Order ID with the App Store API, verifies the transactions, and returns formatted transactions.
* An Order ID is a unique identifier for a transaction that is generated by the App Store and is included in receipt emails, so it is useful for customer support.
*
* @param {AppStoreServerAPIClient} client - The App Store API client.
* @param {string} orderId - The order ID to fetch history for.
* @param {string} rootCaDir - The directory containing Apple root CA certificates for verification.
* @param {Environment} environment - The App Store environment.
* @param {string} bundleId - The bundle ID of the app.
* @param {string | undefined} authenticatedAccountToken - The UUID account token of the user who is authenticated in this request. If undefined, UUID authentication will be skipped.
*
* @returns {Promise<Transaction[]|null>} The validated transactions
*/
async function fetchValidatedTransactionsFromOrderId(client, orderId, rootCaDir, environment, bundleId, authenticatedAccountToken) {
const transactions = await client.lookUpOrderId(orderId);
debug("[Order ID: %s] Fetched transaction history; Found %d transactions", orderId, transactions.signedTransactions);
const rootCAs = readCertificateFiles(rootCaDir);
const decodedTransactions = await verifyAndDecodeTransactions(transactions.signedTransactions, rootCAs, environment, bundleId);
debug("[Order ID: %s] Verified and decoded %d transactions", orderId, decodedTransactions.length);
const validDecodedTransactions = authenticatedAccountToken == undefined ? decodedTransactions : filterTransactionsThatBelongToAccount(decodedTransactions, authenticatedAccountToken);
if (authenticatedAccountToken != undefined) {
debug("[Account token: %s] Filtered transactions that belong to the account UUID. Found %d matching transactions", authenticatedAccountToken, validDecodedTransactions.length);
}
if (validDecodedTransactions.length === 0) {
return null;
}
return validDecodedTransactions.map((decodedTransaction) => {
return transformDecodedTransactionToTransaction(decodedTransaction)
})
}

/**
* Transforms a JWSTransactionDecodedPayload into a Transaction object
*
* @param {JWSTransactionDecodedPayload} decodedTransaction - The decoded transaction to transform.
* @returns {Transaction} The transformed transaction.
*
*/
function transformDecodedTransactionToTransaction(decodedTransaction) {
return {
type: "iap",
id: decodedTransaction.transactionId,
start_date: decodedTransaction.purchaseDate / 1000,
end_date: decodedTransaction.expiresDate / 1000,
purchased_date: decodedTransaction.purchaseDate / 1000,
duration: null
}
}

/**
* Filters out transactions that do not belong to the authorized account token.
*
Expand Down Expand Up @@ -168,7 +239,7 @@ function readCertificateFiles(directory) {
* @param {Buffer[]} rootCAs - Apple root CA certificate contents for verification.
* @param {Environment} environment - The App Store environment.
* @param {string} bundleId - The bundle ID of the app.
* @returns {Promise<Object[]>} The decoded transactions.
* @returns {Promise<JWSTransactionDecodedPayload[]>} The decoded transactions.
*/
async function verifyAndDecodeTransactions(transactions, rootCAs, environment, bundleId) {
const verifier = new SignedDataVerifier(rootCAs, true, environment, bundleId);
Expand Down Expand Up @@ -254,5 +325,5 @@ function getAppStoreEnvironmentFromEnv() {
}

module.exports = {
verify_receipt, verify_transaction_id
verify_receipt, verify_transaction_id, lookup_order_id
};

0 comments on commit 790210d

Please sign in to comment.