// ==========================================
// CONFIGURATION
// ==========================================
// --- --- --- --- --- --- --- --- --- --- --- ---
// --- EDIT THESE VALUES TO MATCH YOUR SETUP ---
// --- --- --- --- --- --- --- --- --- --- --- ---
const CONFIG = {
// Set to true for testing with PayPal Sandbox, false for Live transactions
USE_SANDBOX: true,
// Your default currency code (e.g., "USD", "EUR", "GBP")
CURRENCY_CODE: "GBP",
// Number of days from creation until the invoice is due (Link expiration)
DAYS_UNTIL_DUE: 7,
// Set to true to enable automatic reminders from PayPal (relative to due date)
// Check PayPal docs for specific reminder configurations if needed.
ENABLE_PAYPAL_REMINDERS: true,
// Column to write status messages (e.g., "Link Created", "Error: ...")
// Set to -1 if you don't want a status column.
STATUS_COLUMN_INDEX: 10, // Example: Column J (if A=1, B=2, etc.)
// Optional: Company Name to appear on Invoice (configure fully in PayPal account)
INVOICER_BUSINESS_NAME: "Heighington Christmas Market",
// Optional: Header row number (to skip processing)
HEADER_ROW: 1
};
const COLUMN_MAPPING = {
// --- Column numbers (A=1, B=2, C=3, etc.) ---
INVOICE_NUM: 1, // Column containing Your Invoice Number
EMAIL: 2, // Column containing Customer Email
PRODUCT_DESC: 3, // Column containing Product Description
PRICE: 4, // Column containing Price
PAYPAL_LINK: 5 // Column where the PayPal link will be written
};
// --- --- --- --- --- --- --- --- --- --- --- ---
// --- END OF USER CONFIGURATION ---
// --- --- --- --- --- --- --- --- --- --- --- ---
// ==========================================
// PayPal API Endpoints
const PAYPAL_TOKEN_URL = `${PAYPAL_API_BASE}/v1/oauth2/token`;
const PAYPAL_INVOICE_URL = `${PAYPAL_API_BASE}/v2/invoicing/invoices`;
// --- Script Properties Keys ---
const PROP_CLIENT_ID = 'PAYPAL_CLIENT_ID';
const PROP_CLIENT_SECRET = 'PAYPAL_CLIENT_SECRET';
// ==========================================
// MENU SETUP
// ==========================================
/**
* Runs when the spreadsheet is opened and adds a custom menu.
*/
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('PayPal Tools')
.addItem('1. Setup Credentials', 'setupPayPalCredentials')
.addSeparator()
.addItem('Create Links (Selected Rows)', 'createPayPalLinksForSelectedRows')
.addItem('Create Links (All Rows)', 'createPayPalLinksForAllRows')
.addToUi();
}
/**
* Prompts user for PayPal API credentials and stores them securely.
* Run this ONCE from the Script Editor.
*/
function setupPayPalCredentials() {
const ui = SpreadsheetApp.getUi();
const scriptProperties = PropertiesService.getScriptProperties();
const clientId = ui.prompt('Enter PayPal API Client ID:', ui.ButtonSet.OK_CANCEL);
if (clientId.getSelectedButton() !== ui.Button.OK || !clientId.getResponseText()) {
ui.alert('Setup cancelled.');
return;
}
scriptProperties.setProperty(PROP_CLIENT_ID, clientId.getResponseText().trim());
const clientSecret = ui.prompt('Enter PayPal API Client Secret:', ui.ButtonSet.OK_CANCEL);
if (clientSecret.getSelectedButton() !== ui.Button.OK || !clientSecret.getResponseText()) {
// Consider deleting the client ID if secret setup fails halfway
scriptProperties.deleteProperty(PROP_CLIENT_ID);
ui.alert('Setup cancelled.');
return;
}
scriptProperties.setProperty(PROP_CLIENT_SECRET, clientSecret.getResponseText().trim());
ui.alert('PayPal credentials stored successfully in Script Properties.');
}
// ==========================================
// MAIN PROCESSING FUNCTIONS
// ==========================================
/**
* Processes only the rows currently selected by the user.
*/
function createPayPalLinksForSelectedRows() {
const sheet = SpreadsheetApp.getActiveSheet();
const selectedRanges = sheet.getActiveRangeList().getRanges();
const token = getPayPalAccessToken_(); // Get token once for potentially multiple rows
if (!token) return; // Error handled in getPayPalAccessToken_
SpreadsheetApp.getUi().showSidebar(HtmlService.createHtmlOutput('<p>Processing selected rows...</p>').setTitle('Processing'));
for (const range of selectedRanges) {
for (let i = 1; i <= range.getNumRows(); i++) {
const rowNum = range.getRow() + i - 1;
if (rowNum <= CONFIG.HEADER_ROW) continue; // Skip header
processRow_(sheet, rowNum, token);
}
}
SpreadsheetApp.getUi().showSidebar(HtmlService.createHtmlOutput('<p>Processing complete.</p>').setTitle('Finished'));
Logger.log('Finished processing selected rows.');
}
/**
* Processes all rows in the sheet that have data and haven't been processed.
*/
function createPayPalLinksForAllRows() {
const sheet = SpreadsheetApp.getActiveSheet();
const dataRange = sheet.getDataRange();
const lastRow = dataRange.getLastRow();
const token = getPayPalAccessToken_(); // Get token once
if (!token) return; // Error handled in getPayPalAccessToken_
SpreadsheetApp.getUi().showSidebar(HtmlService.createHtmlOutput('<p>Processing all rows...</p>').setTitle('Processing'));
for (let rowNum = CONFIG.HEADER_ROW + 1; rowNum <= lastRow; rowNum++) {
processRow_(sheet, rowNum, token);
}
SpreadsheetApp.getUi().showSidebar(HtmlService.createHtmlOutput('<p>Processing complete.</p>').setTitle('Finished'));
Logger.log('Finished processing all rows.');
}
/**
* Processes a single row: reads data, creates invoice, writes link/status.
* @param {GoogleAppsScript.Spreadsheet.Sheet} sheet The sheet object.
* @param {number} rowNum The row number to process.
* @param {string} accessToken The PayPal API access token.
*/
function processRow_(sheet, rowNum, accessToken) {
try {
// --- 1. Read Data & Check if Already Processed ---
const linkCell = sheet.getRange(rowNum, COLUMN_MAPPING.PAYPAL_LINK);
if (linkCell.getValue() !== '') {
Logger.log(`Skipping row ${rowNum}: Link already exists.`);
// Optionally update status if you want: updateStatus_(sheet, rowNum, "Skipped: Link exists");
return;
}
const email = sheet.getRange(rowNum, COLUMN_MAPPING.EMAIL).getValue();
const invoiceNum = sheet.getRange(rowNum, COLUMN_MAPPING.INVOICE_NUM).getValue();
const productDesc = sheet.getRange(rowNum, COLUMN_MAPPING.PRODUCT_DESC).getValue();
const price = sheet.getRange(rowNum, COLUMN_MAPPING.PRICE).getValue();
// Basic validation
if (!email || !invoiceNum || !productDesc || !price || typeof price !== 'number' || price <= 0) {
Logger.log(`Skipping row ${rowNum}: Missing or invalid data.`);
updateStatus_(sheet, rowNum, "Error: Missing/Invalid Data");
return;
}
// --- 2. Prepare Invoice Data ---
const invoiceData = {
email: email,
invoiceNumber: invoiceNum.toString(), // Ensure it's a string
productDescription: productDesc,
price: price.toFixed(2) // Ensure 2 decimal places
};
// --- 3. Create and Send Invoice via PayPal API ---
const paymentLink = createAndSendPayPalInvoice_(accessToken, invoiceData);
// --- 4. Update Sheet ---
if (paymentLink) {
linkCell.setValue(paymentLink);
updateStatus_(sheet, rowNum, `Link Created: ${new Date().toLocaleDateString()}`);
Logger.log(`Successfully created link for row ${rowNum}`);
} else {
// Error message should have been logged within createAndSendPayPalInvoice_
updateStatus_(sheet, rowNum, "Error: Failed to create link (see Logs)");
}
} catch (error) {
Logger.log(`Error processing row ${rowNum}: ${error} \n Stack: ${error.stack}`);
updateStatus_(sheet, rowNum, `Error: ${error.message}`);
}
}
// ==========================================
// PAYPAL API HELPER FUNCTIONS
// ==========================================
/**
* Retrieves PayPal credentials and obtains an OAuth2 access token.
* @return {string|null} The access token or null if an error occurs.
* @private
*/
function getPayPalAccessToken_() {
const scriptProperties = PropertiesService.getScriptProperties();
const clientId = scriptProperties.getProperty(PROP_CLIENT_ID);
const clientSecret = scriptProperties.getProperty(PROP_CLIENT_SECRET);
if (!clientId || !clientSecret) {
Logger.log('PayPal Client ID or Secret not set. Please run "Setup Credentials" first.');
SpreadsheetApp.getUi().alert('PayPal Client ID or Secret not set. Please run "Setup Credentials" from the PayPal Tools menu first.');
return null;
}
const options = {
method: 'post',
headers: {
'Authorization': 'Basic ' + Utilities.base64Encode(clientId + ':' + clientSecret),
'Accept': 'application/json',
'Accept-Language': 'en_US'
},
payload: {
'grant_type': 'client_credentials'
},
muteHttpExceptions: true // Prevent script stopping on API errors, handle manually
};
try {
const response = UrlFetchApp.fetch(PAYPAL_TOKEN_URL, options);
const responseCode = response.getResponseCode();
const responseBody = response.getContentText();
if (responseCode === 200) {
const jsonResponse = JSON.parse(responseBody);
Logger.log('Successfully obtained PayPal Access Token.');
return jsonResponse.access_token;
} else {
Logger.log(`Error getting PayPal token. Status: ${responseCode}, Response: ${responseBody}`);
SpreadsheetApp.getUi().alert(`Error getting PayPal token. Status: ${responseCode}. Check Logs for details.`);
return null;
}
} catch (error) {
Logger.log(`Failed to fetch PayPal token: ${error}`);
SpreadsheetApp.getUi().alert(`Failed to fetch PayPal token: ${error.message}. Check Logs.`);
return null;
}
}
/**
* Creates a draft invoice, sends it, and returns the payer view link.
* @param {string} accessToken The valid PayPal OAuth2 access token.
* @param {object} invoiceData Object containing email, invoiceNumber, productDescription, price.
* @return {string|null} The payment link or null on failure.
* @private
*/
function createAndSendPayPalInvoice_(accessToken, invoiceData) {
// --- 1. Calculate Due Date ---
const issueDate = new Date();
const dueDate = new Date(issueDate);
dueDate.setDate(issueDate.getDate() + CONFIG.DAYS_UNTIL_DUE);
const formattedDueDate = Utilities.formatDate(dueDate, Session.getScriptTimeZone(), "yyyy-MM-dd");
const formattedIssueDate = Utilities.formatDate(issueDate, Session.getScriptTimeZone(), "yyyy-MM-dd");
// --- 2. Construct Invoice Payload ---
const payload = {
detail: {
invoice_number: invoiceData.invoiceNumber,
invoice_date: formattedIssueDate,
currency_code: CONFIG.CURRENCY_CODE,
payment_term: {
term_type: "DUE_ON_DATE_SPECIFIED",
due_date: formattedDueDate
},
// Optional Note
// note: "Thank you for your business."
},
invoicer: {
// You might need to fill more details here based on your PayPal setup
Heighington_Christmas_Market: CONFIG.INVOICER_BUSINESS_NAME
// email_address: "bl...@hotmail.com", // Usually not needed if authenticated correctly },
primary_recipients: [
{
billing_info: {
email_address: invoiceData.email
// You can add more recipient details like name, address if available
// name: { given_name: "John", surname: "Doe" }
}
}
],
items: [
{
name: invoiceData.productDescription,
quantity: "1", // Assuming quantity is always 1
unit_amount: {
currency_code: CONFIG.CURRENCY_CODE,
value: invoiceData.price
}
// unit_of_measure: "QUANTITY" // Or HOURS, AMOUNT etc.
}
],
// Amount needs to be calculated based on items, tax, discounts etc.
// For simple cases, PayPal often calculates it if items are provided.
// If you have tax/discounts, add them to items or the 'amount' section explicitly.
// amount: {
// currency_code: CONFIG.CURRENCY_CODE,
// value: invoiceData.price // Simple case, no tax/discount
// },
configuration: {
// partial_payment: { allow_partial_payment: false }, // default is false
// allow_tip: false, // default is false
tax_inclusive: false // default
}
};
// --- 3. Create Draft Invoice Request ---
const optionsCreate = {
method: 'post',
contentType: 'application/json',
headers: {
'Authorization': 'Bearer ' + accessToken
// 'PayPal-Request-Id': 'INV-' + invoiceData.invoiceNumber + '-' + Date.now() // Optional: For tracking
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
try {
Logger.log(`Creating draft invoice for: ${invoiceData.invoiceNumber}`);
const responseCreate = UrlFetchApp.fetch(PAYPAL_INVOICE_URL, optionsCreate);
const responseCodeCreate = responseCreate.getResponseCode();
const responseBodyCreate = responseCreate.getContentText();
if (responseCodeCreate === 201) { // 201 Created for draft invoice
const jsonResponseCreate = JSON.parse(responseBodyCreate);
const invoiceId = jsonResponseCreate.id;
const invoiceHref = jsonResponseCreate.href; // URL of the created invoice resource
Logger.log(`Draft invoice created successfully. ID: ${invoiceId}`);
// --- 4. Send the Invoice ---
const sendUrl = `${invoiceHref}/send`; // Use HATEOAS link from response
// Alternative fixed URL: const sendUrl = `${PAYPAL_INVOICE_URL}/${invoiceId}/send`;
const optionsSend = {
method: 'post',
contentType: 'application/json',
headers: {
'Authorization': 'Bearer ' + accessToken
},
// Body for send can include subject, note etc., or be empty {}
payload: JSON.stringify({
send_to_invoicer: false, // Send only to recipient?
send_to_recipient: true,
subject: `Invoice ${invoiceData.invoiceNumber} from ${CONFIG.INVOICER_BUSINESS_NAME || 'Your Company'}`,
note: `Invoice ${invoiceData.invoiceNumber}. Payment due by ${formattedDueDate}.`
}),
muteHttpExceptions: true
};
Logger.log(`Sending invoice ID: ${invoiceId}`);
const responseSend = UrlFetchApp.fetch(sendUrl, optionsSend);
const responseCodeSend = responseSend.getResponseCode();
const responseBodySend = responseSend.getContentText();
if (responseCodeSend === 200 || responseCodeSend === 202 ) { // 200 OK or 202 Accepted for send
Logger.log(`Invoice ${invoiceId} sent successfully.`);
// The payer view link is usually in the response body after sending
const jsonResponseSend = JSON.parse(responseBodySend);
if (jsonResponseSend && jsonResponseSend.href) {
// Sometimes the Send response directly contains the payer link
// Need to verify exact structure from PayPal Sandbox response
// It might be under a different key or require a subsequent GET request
Logger.log("Send response: " + responseBodySend); // Log to inspect
// --- 5. (Alternative/Robust) Get Invoice Details to find Payer Link ---
// If the send response doesn't reliably contain the link, fetch the invoice details again.
const optionsGet = {
method: 'get',
headers: { 'Authorization': 'Bearer ' + accessToken },
muteHttpExceptions: true
};
const responseGet = UrlFetchApp.fetch(invoiceHref, optionsGet);
if (responseGet.getResponseCode() === 200) {
const invoiceDetails = JSON.parse(responseGet.getContentText());
if (invoiceDetails.links) {
const payerLinkObj = invoiceDetails.links.find(link => link.rel === 'payer-view');
if (payerLinkObj && payerLinkObj.href) {
Logger.log(`Found payer link for ${invoiceId}: ${payerLinkObj.href}`);
return payerLinkObj.href;
}
}
}
Logger.log(`Could not find 'payer-view' link after sending invoice ${invoiceId}. Check GET response or Send Response structure in logs.`);
return null; // Link not found reliably
} else {
Logger.log(`Invoice ${invoiceId} sent, but Send response did not contain expected link structure. Body: ${responseBodySend}`);
// Fallback to trying GET request
// This part is duplicated from above block, could be refactored.
const optionsGet = {
method: 'get',
headers: { 'Authorization': 'Bearer ' + accessToken },
muteHttpExceptions: true
};
const responseGet = UrlFetchApp.fetch(invoiceHref, optionsGet);
if (responseGet.getResponseCode() === 200) {
const invoiceDetails = JSON.parse(responseGet.getContentText());
if (invoiceDetails.links) {
const payerLinkObj = invoiceDetails.links.find(link => link.rel === 'payer-view');
if (payerLinkObj && payerLinkObj.href) {
Logger.log(`Found payer link for ${invoiceId} via GET: ${payerLinkObj.href}`);
return payerLinkObj.href;
}
}
}
Logger.log(`Could not find 'payer-view' link after sending invoice ${invoiceId} via Send response or subsequent GET. Check Logs.`);
return null;
}
} else {
Logger.log(`Error sending PayPal invoice ${invoiceId}. Status: ${responseCodeSend}, Response: ${responseBodySend}`);
return null;
}
} else {
Logger.log(`Error creating PayPal draft invoice. Status: ${responseCodeCreate}, Response: ${responseBodyCreate}`);
return null;
}
} catch (error) {
Logger.log(`Failed during PayPal invoice creation/sending: ${error} \n Stack: ${error.stack}`);
return null;
}
}
/**
* Updates the status column for a given row.
* @param {GoogleAppsScript.Spreadsheet.Sheet} sheet The sheet object.
* @param {number} rowNum The row number to update.
* @param {string} message The status message to write.
* @private
*/
function updateStatus_(sheet, rowNum, message) {
if (CONFIG.STATUS_COLUMN_INDEX > 0) {
sheet.getRange(rowNum, CONFIG.STATUS_COLUMN_INDEX).setValue(message);
}
}