Auto Card Recon

Automatically upload all your card recon reciepts and descriptions using the template 123.45 USD - PLACE - LINE ITEM.pdf format

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Auto Card Recon
// @namespace    http://tampermonkey.net/
// @version      2.1
// @description  Automatically upload all your card recon reciepts and descriptions using the template 123.45 USD - PLACE - LINE ITEM.pdf format 
// @author       Gemini & Elder Benjamin Finch
// @match        https://card.churchofjesuschrist.org/psc/card/*
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- SCRIPT INITIALIZATION GUARD ---
    if (window.self !== window.top) {
        console.log('[Recon Script] Running inside an iframe, aborting.');
        return;
    }

    // --- UTILITY AND HELPER FUNCTIONS ---

    const log = (message) => console.log(`[Recon Script] ${message}`);
    const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

    const waitForElement = (selector, context = document, timeout = 30000) => {
        return new Promise((resolve, reject) => {
            log(`Waiting for element: "${selector}"`);
            const intervalTime = 100;
            let elapsedTime = 0;
            const interval = setInterval(() => {
                const element = context.querySelector(selector);
                if (element) {
                    log(`Found element: "${selector}"`);
                    clearInterval(interval);
                    resolve(element);
                } else {
                    elapsedTime += intervalTime;
                    if (elapsedTime >= timeout) {
                        clearInterval(interval);
                        const errorMsg = `Timeout waiting for element: "${selector}"`;
                        log(`TIMEOUT: ${errorMsg}`);
                        reject(new Error(errorMsg));
                    }
                }
            }, intervalTime);
        });
    };

    const findNewestIframe = async (currentIframeCount, timeout = 20000) => {
        log(`Waiting for a new iframe to appear. Current count: ${currentIframeCount}`);
        return new Promise((resolve, reject) => {
            let elapsedTime = 0;
            const interval = setInterval(() => {
                const allIframes = document.querySelectorAll('iframe[id^="ptModFrame_"]');
                if (allIframes.length > currentIframeCount) {
                    const newIframe = allIframes[allIframes.length - 1];
                    log(`Detected new iframe: ${newIframe.id}`);
                    clearInterval(interval);
                    resolve(newIframe);
                } else {
                    elapsedTime += 250;
                    if (elapsedTime >= timeout) {
                        clearInterval(interval);
                        reject(new Error(`Timeout: Waited for a new iframe but count did not increase from ${currentIframeCount}.`));
                    }
                }
            }, 250);
        });
    };


    // --- UI COMPONENTS ---

    function addStyles() {
        GM_addStyle(`
            #recon-float-button {
                position: fixed; bottom: 20px; right: 20px; z-index: 9999;
                background-color: #007bff; color: white; border: none; border-radius: 5px;
                padding: 10px 15px; font-size: 16px; cursor: pointer;
                box-shadow: 0 4px 8px rgba(0,0,0,0.2);
            }
            #recon-float-button:hover { background-color: #0056b3; }
            #recon-float-button:disabled { background-color: #cccccc; cursor: not-allowed; }
            .recon-modal-overlay {
                position: fixed; top: 0; left: 0; width: 100%; height: 100%;
                background: rgba(0, 0, 0, 0.7); z-index: 10000; display: flex;
                justify-content: center; align-items: center;
            }
            .recon-modal-content {
                background: white; padding: 20px; border-radius: 8px;
                width: 80%; max-width: 800px; max-height: 90vh; overflow-y: auto;
            }
            .recon-modal-table { width: 100%; border-collapse: collapse; margin-top: 15px; }
            .recon-modal-table th, .recon-modal-table td { border: 1px solid #ddd; padding: 8px; text-align: left; }
            .recon-modal-table th { background-color: #f2f2f2; }
            .recon-modal-input { width: 95%; padding: 5px; }
            .recon-modal-footer { margin-top: 20px; text-align: right; }
        `);
    }

    function createFloatingButton() {
        const button = document.createElement('button');
        button.id = 'recon-float-button';
        button.textContent = 'Start Recon';
        document.body.appendChild(button);

        const fileInput = document.createElement('input');
        fileInput.type = 'file';
        fileInput.multiple = true;
        fileInput.accept = '.pdf';
        fileInput.style.display = 'none';
        document.body.appendChild(fileInput);

        button.addEventListener('click', () => fileInput.click());
        fileInput.addEventListener('change', handleFileSelect);
    }


    // --- CORE LOGIC ---

    async function handleFileSelect(event) {
        const button = document.getElementById('recon-float-button');
        button.textContent = 'Processing...';
        button.disabled = true;

        const files = event.target.files;
        if (!files.length) {
            log('No files selected.');
            button.textContent = 'Start Recon';
            button.disabled = false;
            return;
        }

        log(`Selected ${files.length} files.`);
        const { validReceipts, invalidReceipts } = parseFileNames(Array.from(files));

        let allReceipts = validReceipts;
        if (invalidReceipts.length > 0) {
            try {
                const manuallyCorrected = await showValidationModal(invalidReceipts);
                allReceipts = [...validReceipts, ...manuallyCorrected];
            } catch (error) {
                log('Modal was cancelled by user.');
                button.textContent = 'Start Recon';
                button.disabled = false;
                return;
            }
        }

        if (allReceipts.length > 0) {
            await processTransactions(allReceipts);
        } else {
            log('No valid receipts to process.');
        }

        button.textContent = 'Done!';
        setTimeout(() => {
            button.textContent = 'Start Recon';
            button.disabled = false;
        }, 5000);
    }

    function parseFileNames(files) {
        const validReceipts = [];
        const invalidReceipts = [];
        files.forEach(file => {
            const name = file.name.replace(/\.pdf$/i, '');
            const parts = name.split('-').map(p => p.trim());
            if (parts.length >= 2) {
                const valueAndCurrency = parts[0].split(' ');
                const value = parseFloat(valueAndCurrency[0].replace(/,/g, ''));
                const currency = valueAndCurrency.length > 1 ? valueAndCurrency[1].toUpperCase() : 'UNKNOWN';
                const lineItem = parts[parts.length - 1];
                if (!isNaN(value) && currency !== 'UNKNOWN') {
                    validReceipts.push({ file, value, currency, lineItem });
                } else {
                    invalidReceipts.push({ file });
                }
            } else {
                invalidReceipts.push({ file });
            }
        });
        log(`Parsed files: ${validReceipts.length} valid, ${invalidReceipts.length} invalid.`);
        return { validReceipts, invalidReceipts };
    }

    function showValidationModal(invalidReceipts) {
        return new Promise((resolve, reject) => {
            const modalOverlay = document.createElement('div');
            modalOverlay.className = 'recon-modal-overlay';
            const tableRows = invalidReceipts.map((receipt) => `
                <tr>
                    <td>${receipt.file.name}</td>
                    <td><input type="text" class="recon-modal-input" data-type="value" placeholder="e.g., 123.45 MZN"></td>
                    <td><input type="text" class="recon-modal-input" data-type="lineItem" placeholder="e.g., DIESEL FUEL"></td>
                </tr>
            `).join('');

            modalOverlay.innerHTML = `
                <div class="recon-modal-content">
                    <h2>Invalid Filenames</h2>
                    <p>Please correct the value and line item for the following files:</p>
                    <table class="recon-modal-table">
                        <thead><tr><th>Filename</th><th>Value (e.g., 123.45 MZN)</th><th>Line Item</th></tr></thead>
                        <tbody>${tableRows}</tbody>
                    </table>
                    <div class="recon-modal-footer">
                        <button id="recon-modal-cancel">Cancel</button>
                        <button id="recon-modal-submit" style="margin-left: 10px;">Submit</button>
                    </div>
                </div>`;
            document.body.appendChild(modalOverlay);

            document.getElementById('recon-modal-submit').addEventListener('click', () => {
                const correctedReceipts = [];
                let allValid = true;
                const rows = modalOverlay.querySelectorAll('tbody tr');
                rows.forEach((row, index) => {
                    const valueInput = row.querySelector('input[data-type="value"]');
                    const lineItemInput = row.querySelector('input[data-type="lineItem"]');
                    const valueStr = valueInput.value.trim();
                    const lineItem = lineItemInput.value.trim();
                    if (!valueStr || !lineItem) {
                        allValid = false;
                        valueInput.style.borderColor = valueStr ? '' : 'red';
                        lineItemInput.style.borderColor = lineItem ? '' : 'red';
                        return;
                    }
                    const valueParts = valueStr.split(' ');
                    const value = parseFloat(valueParts[0].replace(/,/g, ''));
                    const currency = valueParts.length > 1 ? valueParts[1].toUpperCase() : 'UNKNOWN';
                    if (isNaN(value) || currency === 'UNKNOWN') {
                        allValid = false;
                        valueInput.style.borderColor = 'red';
                        return;
                    }
                    correctedReceipts.push({ file: invalidReceipts[index].file, value, currency, lineItem });
                });

                if (allValid) {
                    document.body.removeChild(modalOverlay);
                    resolve(correctedReceipts);
                } else {
                    alert('Please fill all fields correctly.');
                }
            });

            document.getElementById('recon-modal-cancel').addEventListener('click', () => {
                document.body.removeChild(modalOverlay);
                reject(new Error("User cancelled the modal."));
            });
        });
    }

    async function processTransactions(receipts) {
        log('Starting transaction processing...');
        const processedIndices = new Set();
        for (const receipt of receipts) {
            log(`Searching for transaction for: ${receipt.file.name}`);
            const transactionRows = document.querySelectorAll('#win0sidedivEX_SHEET_DTL_GROUP\\$0 ul.ps_grid-body > li.ps_grid-row');
            let matchFound = false;
            for (let i = 0; i < transactionRows.length; i++) {
                if (processedIndices.has(i)) continue;
                const row = transactionRows[i];
                const amountEl = row.querySelector(`[id^='MONETARY_AMT_DTL\\$']`);
                const currencyEl = row.querySelector(`[id^='CURRENCY_CD_DTL\\$']`);
                if (amountEl && currencyEl) {
                    const pageAmount = parseFloat(amountEl.textContent.replace(/,/g, ''));
                    const pageCurrency = currencyEl.textContent.trim().toUpperCase();

                    // --- THIS IS THE UPDATED LINE ---
                    // Instead of exact '===', check if the difference is less than a cent.
                    if (Math.abs(pageAmount - receipt.value) < 0.01 && pageCurrency === receipt.currency) {
                        log(`Match found for ${receipt.value} ${receipt.currency}`);
                        try {
                            await processSingleTransaction(row, receipt);
                            processedIndices.add(i);
                            matchFound = true;
                            break;
                        } catch (e) {
                            log(`ERROR processing transaction: ${e.message}`);
                            alert(`An error occurred processing ${receipt.file.name}. Check console. Continuing...`);
                            break;
                        }
                    }
                }
            }
            if (!matchFound) {
                log(`No matching transaction found for receipt: ${receipt.file.name}`);
            }
        }
        log('All processing complete.');
    }


    // --- TRANSACTION PROCESSING FUNCTION ---

    async function processSingleTransaction(rowElement, receipt) {
        log(`--- Starting processing for: ${receipt.lineItem} ---`);

        // Step 1: Set description, click away, and wait patiently.
        log("Step 1: Setting description on main page.");
        rowElement.click();
        await sleep(4000);
        const descriptionBox = await waitForElement('#DESCR\\$0');
        descriptionBox.value = receipt.lineItem;
        descriptionBox.dispatchEvent(new Event('change', { bubbles: true }));
        log(`Set description to: "${receipt.lineItem}"`);
        descriptionBox.blur();
        log("Clicked away from description box. Waiting for system to process...");
        await sleep(4000);

        const attachButton = await waitForElement("a[id^='EX_LINE_WRK_ATTACH_PB']");
        let currentIframeCount = document.querySelectorAll('iframe[id^="ptModFrame_"]').length;
        attachButton.click();
        log('Clicked "Attach Receipt".');

        // Step 2: Dynamically find the first modal iframe.
        const iframe0 = await findNewestIframe(currentIframeCount);
        log(`Waiting for content in ${iframe0.id}...`);
        await sleep(3500);
        const iframeDoc0 = iframe0.contentDocument || iframe0.contentWindow.document;
        if (!iframeDoc0) throw new Error("Could not get content document from first iframe.");
        log(`Successfully accessed document of ${iframe0.id}.`);

        const addAttachmentButton = await waitForElement("a[id^='C_EX_ATT_WRK_ATTACHADD']", iframeDoc0);
        currentIframeCount = document.querySelectorAll('iframe[id^="ptModFrame_"]').length;
        addAttachmentButton.click();
        log("Clicked 'Add Attachment'.");

        // Step 3: Dynamically find the second (nested) modal iframe.
        const iframe1 = await findNewestIframe(currentIframeCount);
        log(`Waiting for content in ${iframe1.id}...`);
        await sleep(3500);
        const iframeDoc1 = iframe1.contentDocument || iframe1.contentWindow.document;
        if (!iframeDoc1) throw new Error("Could not get content document from second iframe.");
        log(`Successfully accessed document of ${iframe1.id}.`);

        // File upload process inside the second iframe.
        const fileInput = await waitForElement('input[type="file"]#\\#ICOrigFileName', iframeDoc1);
        const dataTransfer = new DataTransfer();
        dataTransfer.items.add(receipt.file);
        fileInput.files = dataTransfer.files;
        fileInput.dispatchEvent(new Event('change', { bubbles: true }));
        log(`Attached file: ${receipt.file.name}`);
        await sleep(1500);

        const uploadButton = await waitForElement('a#\\#ICUpload', iframeDoc1);
        if (uploadButton && uploadButton.style.display !== 'none') {
            uploadButton.click();
            log('Clicked "Upload"');
        } else { throw new Error('Upload button not found or not visible.'); }

        await waitForElement('.ps_attach-completetext', iframeDoc1, 60000);
        log('Upload complete.');
        await sleep(2000);

        await (await waitForElement('a#\\#ICOK', iframeDoc1)).click();
        log(`Clicked "Done" in ${iframe1.id}, closing it.`);

        // Step 4: Return to the first iframe's context and finish.
        log(`Step 4: Context is now back in ${iframe0.id}.`);
        await sleep(4000);

        const attachDescriptionInput = await waitForElement("input[id^='ATTACH_DESCR\\$']", iframeDoc0);
        attachDescriptionInput.value = receipt.lineItem;
        attachDescriptionInput.dispatchEvent(new Event('change', { bubbles: true }));
        log(`Set attachment description to: "${receipt.lineItem}"`);
        await sleep(1500);

        await (await waitForElement("a#\\#ICSave", iframeDoc0)).click();
        log(`Finished processing. Closing main attachment modal.`);

        log(`--- Completed: ${receipt.lineItem}. Pausing before next transaction... ---`);
        await sleep(7000);
    }


    // --- SCRIPT INITIALIZATION ---

    log('Script loaded. Initializing on main page.');
    addStyles();
    createFloatingButton();

})();