Issue with ZATCA Integration in Manager.io

:pushpin: Temporary Solution I Have Tried for Displaying QR Code Phase II

This is a temporary solution that I have personally tested and confirmed to work:

Make sure to back up your Business Data before trying it.

  1. Set a Custom Field (Base64 QR Code) to Appear on Printed Document
    Configure a custom field to contain the Base64 QR code, and ensure it is visible on the printed version of the invoice.

  1. Create a Custom Theme to Display QR Code Phase II
    Develop a custom invoice theme specifically designed to show the QR code from the custom field.
    Make sure to record the GUID of the custom theme you’ve created.

<!DOCTYPE html>
<html>
<head>
	<meta charset="UTF-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1.0" />
	<!-- Styling for consistent look and feel -->
	<style>
		*, ::after, ::before, ::backdrop, ::file-selector-button {
			margin: 0;
			padding: 0;
		}

		*, ::after, ::before, ::backdrop, ::file-selector-button {
			box-sizing: border-box;
			border: 0 solid;
		}

		body {
			margin: 0;
			padding: 30px;
			font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
			color: #171717;
			font-size: 12px;
			line-height: 1.428571429;
		}

		table {
			font-size: 12px;
		}

		tr#table-headers th {
			font-weight: bold;
			padding: 5px 10px;
			border: 1px solid #000;
			text-align: start
		}

		tbody#table-rows td {
			padding: 5px 10px;
			border-left: 1px solid #000;
			border-right: 1px solid #000;
			text-align: start;
			vertical-align: top
		}

		tbody#table-rows tr:last-child td {
			padding-bottom: 30px;
			border-bottom: 1px solid #000;
		}
	</style>
</head>
<body>

	<!-- Top section: document title and optional business logo -->
	<table style="margin-bottom: 20px; width: 100%">
		<tbody>
			<tr>
				<td style="vertical-align: top">
					<div style="font-size: 32px; line-height: 32px; font-weight: bold" id="title">Invoice</div>
				</td>
				<td style="text-align: end" id="business-logo">
				</td>
			</tr>
		</tbody>
	</table>

	<!-- Second section: recipient info, document fields (like date, invoice #), and business info -->
	<table style="margin-bottom: 20px; width: 100%">
		<tbody>
			<tr>
				<td style="vertical-align: top" id="recipient-info"></td>
				<td style="text-align: end; vertical-align: top" id="fields"></td>
				<td style="width: 20px"></td>
				<td style="width: 1px; border-left-width: 1px; border-left-color: #000; border-left-style: solid"></td>
				<td style="width: 20px"></td>
				<td style="width: 1px; white-space: nowrap; vertical-align: top" id="business-info"></td>
			</tr>
		</tbody>
	</table>

	<!-- Description block -->
	<div style="font-weight: bold; font-size: 14px; margin-bottom: 20px" id="description"></div>

	<!-- Main table containing column headers, line items, and totals -->
	<table style="border-collapse: collapse; width: 100%">
		<thead>
			<tr id="table-headers"></tr>
		</thead>
		<tbody id="table-rows">
		</tbody>
		<tbody id="totals">
		</tbody>
	</table>

	<script src="resources/qrcode/qrcode.js"></script>
	<div id="qrcode" style="margin-bottom: 20px"></div>

	<!-- Section for any additional custom fields -->
	<div id="custom-fields"></div>

	<!-- Section for footers -->
	<table><tr><td><div id="footers"></div></td></tr></table>

	<!-- Section for final status (e.g. PAID, VOID) with special styling -->
	<div id="status" style="text-align: center"></div>

	<script>
		// Listen for messages sent from the parent frame (via postMessage API)
		window.addEventListener("message", (event) => {

			if (event.source !== window.parent) return; // Only accept messages from parent
			if (event.data.type !== 'context-response') return; // Ignore irrelevant messages

			// Extract the main data object sent from parent
			const data = event.data.body;

			// Set text direction (LTR or RTL) for the whole document
			document.documentElement.dir = data.direction;

			// Populate title and description if provided
			document.getElementById("title").innerHTML = data.title || "No title";
			document.getElementById("description").innerHTML = data.description || "";

			// Inject business logo image if available
			var businessLogoTd = document.getElementById("business-logo");
			if (data.business.logo) {
				const img = document.createElement("img");
				img.src = data.business.logo;
				img.style = "max-height: 150px; max-width: 300px; display: inline";
				businessLogoTd.appendChild(img);
			}

			// Populate business info section with name and address (convert line breaks)
			const business = data.business || {};
			document.getElementById("business-info").innerHTML = `<strong>${business.name || ""}</strong><br>${business.address ? business.address.replace(/\n/g, "<br>") : ""}`;

			// Populate recipient info section with name and address (convert line breaks)
			const recipient = data.recipient || {};
			document.getElementById("recipient-info").innerHTML = `<strong>${recipient.name || ""}</strong><br>${recipient.address ? recipient.address.replace(/\n/g, "<br>") : ""}`;

			// Insert fields (e.g. issue date, due date) into right-hand side
			const fieldsDiv = document.getElementById("fields");
			fieldsDiv.innerHTML = "";
			(data.fields || []).forEach(f => {
				const div = document.createElement("div");
				div.innerHTML = `<strong>${f.label}</strong><br />${f.text || ""}<br /><br />`;
				fieldsDiv.appendChild(div);
			});

			// Build table headers dynamically based on `columns` definition
			const headersRow = document.getElementById("table-headers");
			headersRow.innerHTML = "";
			(data.table.columns || []).forEach(col => {
				const th = document.createElement("th");
				th.innerHTML = col.label;
				th.style.textAlign = col.align;
				if (col.minWidth) {
					th.style.whiteSpace = 'nowrap';
					th.style.width = '1px';
				}
				else if (col.nowrap) {
					th.style.whiteSpace = 'nowrap';
					th.style.width = '80px';
				}
				headersRow.appendChild(th);
			});

			// Populate main table body with rows and alignments based on column definitions
			const rowsBody = document.getElementById("table-rows");
			rowsBody.innerHTML = "";
			(data.table.rows || []).forEach(row => {
				const tr = document.createElement("tr");
				row.cells.forEach((cell, i) => {
					var col = data.table.columns[i];
					const td = document.createElement("td");
					td.innerHTML = (cell.text || "").split("\n").join("<br />");
					td.style.textAlign = col.align;
					if (col.minWidth) {
						td.style.whiteSpace = 'nowrap';
						td.style.width = '1px';
					}
					else if (col.nowrap) {
						td.style.whiteSpace = 'nowrap';
						td.style.width = '80px';
					}
					tr.appendChild(td);
				});
				rowsBody.appendChild(tr);
			});

			// Populate totals section with subtotals, taxes, grand total, etc.
			const totalsBody = document.getElementById("totals");
			totalsBody.innerHTML = "";
			(data.table.totals || []).forEach(total => {
				const tr = document.createElement("tr");
				const tdLabel = document.createElement("td");
				const tdValue = document.createElement("td");
				tdLabel.innerHTML = total.label;
				tdLabel.colSpan = data.table.columns.length - 1;
				tdLabel.style = 'padding: 5px 10px; text-align: end; vertical-align: top';
				tdValue.innerHTML = total.text;
				tdValue.id = total.key;
				if (total.class) tdValue.classList.add(total.class);
				tdValue.dataset.value = total.number;
				tdValue.style = 'padding: 5px 10px; border: 1px solid #000; text-align: right; white-space: nowrap; vertical-align: top';

				// Bold totals if marked as 'emphasis'
				if (total.emphasis) {
					tdLabel.style.fontWeight = 'bold';
					tdValue.style.fontWeight = 'bold';
				}
				tr.appendChild(tdLabel);
				tr.appendChild(tdValue);
				totalsBody.appendChild(tr);
			});

			// Render custom fields section below the table (e.g. notes, terms)
			const customFieldsDiv = document.getElementById('custom-fields');
			customFieldsDiv.innerHTML = '';
			
			let qrcodetext = '';
			(data.custom_fields || []).forEach(f => {
                if (f.label === 'Base64 QRCode') {
                    qrcodetext = f.text;
                } else {
                    const div = document.createElement('div');
                    div.innerHTML = `<strong>${f.label}</strong><br />${(f.text || '').split('\n').join('<br />')}<br /><br />`;
                    if (f.displayAtTheTop === true) {
                        fieldsDiv.appendChild(div);
                    } else {
                        customFieldsDiv.appendChild(div);
                    }
                }
            });
            

			// Render footers section
			const footersDiv = document.getElementById("footers");
			footersDiv.innerHTML = "";
			(data.footers || []).forEach(f => {
				const div = document.createElement("div");
				div.style = 'margin-top: 20px';
				div.innerHTML = f;
				footersDiv.appendChild(div);

				// Find and execute any script tags
				const scripts = div.querySelectorAll("script");
				scripts.forEach(script => {
					const newScript = document.createElement("script");

					// Copy script attributes if needed
					for (const attr of script.attributes) {
						newScript.setAttribute(attr.name, attr.value);
					}

					// Inline script handling
					if (script.textContent) {
						newScript.textContent = script.textContent;
					}

					// Replace the old <script> with the new one so it executes
					script.parentNode.replaceChild(newScript, script);
				});
			});

			// Display status label (e.g. PAID, CANCELLED) with colored border based on status type
			const statusDiv = document.getElementById("status");
			if (data.emphasis?.text != null) {
				statusDiv.style.marginTop = '40px';
				const span = document.createElement("span");
				span.style = 'border-width: 5px; border-color: #FF0000; border-style: solid; padding: 10px; font-size: 20px; text-transform: uppercase';
				if (data.emphasis.positive) {
					span.style.color = 'green';
					span.style.borderColor = 'green';
				}
				if (data.emphasis.negative) {
					span.style.color = 'red';
					span.style.borderColor = 'red';
				}
				span.innerHTML = data.emphasis.text;
				statusDiv.appendChild(span);
			}

			const qrcodeDiv = document.getElementById('qrcode');
			
            // This handles QR code Phase II on invoices for Saudi Arabia
            if (qrcodetext && qrcodetext.length > 10 && qrcodeDiv) {
            	qrcodeDiv.innerHTML = '';
            	renderQRCode(qrcodetext, 'qrcode'); 
			} else {
				// This handles QR code on invoices for Saudi Arabia - this is here just temporarily. Better way will be implemented.
    			if (data.type === 'salesinvoice') {
    				if (data.business.country === 'ar-SA' || data.business.country === 'en-SA') {
    
    					function appendTLV(tag, text, byteList) {
    						const encoded = new TextEncoder().encode(text);
    						byteList.push(tag);
    						byteList.push(encoded.length);
    						for (let b of encoded) byteList.push(b);
    					}
    
    					const byteList = [];
    
    					let businessName = 'No name';
    					if (data.business) businessName = data.business.name;
    
    					let vatNumber = '0000000000000';
    					let vatField = data.business.custom_fields.find(item => item.key === 'd96d97e8-c857-42c6-8360-443c06a13de9');
    					if (vatField) vatNumber = vatField.text;
    
    					let timestamp = new Date((data.timestamp - 621355968000000000) / 10000).toISOString();
    
    					let total = 0;
    					let totalElement = document.getElementById('Total');
    					if (totalElement != null) total = parseFloat(totalElement.getAttribute('data-value'));
    
    					let vat = 0;
    					let taxAmounts = document.getElementsByClassName('taxAmount');
    					for (let i = 0; i < taxAmounts.length; i++) {
    						vat += parseFloat(taxAmounts[i].getAttribute('data-value'));
    					}
    
    					appendTLV(1, businessName, byteList);
    					appendTLV(2, vatNumber, byteList);
    					appendTLV(3, timestamp, byteList);
    					appendTLV(4, total.toFixed(2), byteList);
    					appendTLV(5, vat.toFixed(2), byteList);
    
    					// Convert to Uint8Array
    					const tlvBytes = Uint8Array.from(byteList);
    
    					// Convert to Base64
    					const qrData = btoa(String.fromCharCode(...tlvBytes));
    
    					console.log(qrData);
    
    					new QRCode(document.getElementById('qrcode'), {
    						text: qrData,
    						width: 128,
    						height: 128,
    						colorDark: '#000000',
    						colorLight: '#ffffff',
    						correctLevel: QRCode.CorrectLevel.L
    					});
    				}
    			}
			}
        
            function renderQRCode(content, elementId) {
                new QRCode(document.getElementById(elementId), {
                    text: content,
                    width: 160,
                    height: 160,
                    colorDark: '#000000',
                    colorLight: '#fafafa',
                    correctLevel: QRCode.CorrectLevel.L
                });
        
                // Tambahan opsional: parsing isi QR untuk tooltip
                let details = parseQRCodeContent(content);
                if (details.size > 0) {
                    let title = Array.from(details)
                        .map(([tag, value]) => `Tag ${tag} : ${value.join(', ')}`)
                        .join('\n');
                    const qrCodeDiv = document.getElementById(elementId);
                    if (qrCodeDiv) qrCodeDiv.title = title.trim();
                }
            }
        
            function parseQRCodeContent(qrCodeBase64) {
                let details = new Map();
                try {
                    let data = atob(qrCodeBase64.replace(/\+/g, '+'));
                    let index = 0;
                    while (index < data.length) {
                        let tag = data.charCodeAt(index++);
                        let length = data.charCodeAt(index++);
                        let value = data.substr(index, length);
                        index += length;
                        let decodedValue = (tag === 8 || tag === 9)
                            ? [...value].map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ').toUpperCase()
                            : new TextDecoder('utf-8').decode(new Uint8Array([...value].map(c => c.charCodeAt(0))));
                        details.set(tag, [decodedValue]);
                    }
                } catch (ex) {
                    console.error('Error decoding QR code: ' + ex.message);
                }
                return details;
            }

		}, false);

		// Request context data from parent frame when page loads
		window.addEventListener("load", () =>
			window.parent.postMessage({ type: "context-request" }, "*")
		);
	</script>

</body>
</html>
  1. Batch Update All Reported Invoices
    Perform a batch update for all invoices that have already been REPORTED or CLEARED:
  • Remove the old footer containing the QR Code Phase II.
  • Apply the new custom theme (using the saved GUID) to those invoices.

  1. Remove QR Code Phase II Footers
    Go to Settings → Footers and delete the QrCodePhaseII footer from both Sales Invoices and Credit Notes, since it is no longer used.

  1. Continue Using the Custom Theme for New Invoices
    Going forward, use the custom theme for all future invoices to ensure the QR code displays correctly.