# Distributed Fiscal Data Storage in Shopify ## **Architecture Proposal: Distributed Fiscal Data Storage in Shopify** ### **1. Core Objective & Rationale (Why this approach?)** The goal is to store Ukrainian PRRO fiscal receipts (HTML for display, XML for legal compliance) directly within a Shopify Order’s database. This shifts the legal responsibility of data retention to the merchant's infrastructure. Storing multiple raw HTML and XML strings in a single JSON array introduces critical vulnerabilities: strict Shopify payload limits (128 KB per JSON metafield), string-escaping parsing errors, and race conditions if multiple receipts are generated simultaneously. To solve this, we are deploying a **distributed log / relational pattern** using unstructured Shopify metafields. By decoupling the index (list of receipts) from the metadata and the heavy storage payloads, we create an append-only architecture that is infinitely scalable per order, mathematically immune to data-loss via race conditions, and completely safe for native Liquid rendering. *** ### **2. Proposed Data Structure** All data will be saved under the namespace `hutko`. For every new receipt, the middleware will write/update three distinct metafields: #### **Tier 1: The Index (****`fiscal_receipts`****)** A simple JSON array containing only the receipt IDs. This acts as the pointer for App/template to iterate over. * **Key:**`fiscal_receipts` * **Type:**`json` * **Value:**`["123456789", "987654321"]` ```json { "metafields": [ { "ownerId": "gid://shopify/Order/1234567890", "namespace": "hutko", "key": "fiscal_receipts", "type": "json", "value": "[\"123456789\",\"987654321\"]" } ] } ``` ```graphql mutation SaveFiscalIndex($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { metafields { id namespace key value type } userErrors { field message } } } ``` #### **Tier 2: Lightweight Metadata (****`fiscal_info_{receipt_id}`****)** Contains the immutable fiscal data and recovery links. * **Key Example:**`fiscal_info_rQrCd4DF69nVnKfLM6GhRQvIx` * **Type:**`json` * **Value:** ```json { "payment_id": "rQrCd4DF69nVnKfLM6GhRQvIx", "fiscal_date": "20042026", "fiscal_time": "135110", "fiscal_number": "4000024349", //PRRO number "mac": "jwKFGE6HiqxxVJw_PhmfR2C74azcXl0urwD-_qHcoWE", // optional "display_datetime": "20.04.2026 13:51:10", "amount": 1500.50, "links": { "external_provider": "https://...", //checkbox url "tax_service": "https://...", // with mac to comply with tax service qr code format "pdf": "https://...", // optional "png": "https://..." //optional } } ``` ```graphql mutation SaveFiscalMetadata($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { metafields { id namespace key value type } userErrors { field message } } } ``` ```json { "metafields": [ { "ownerId": "gid://shopify/Order/1234567890", "namespace": "hutko", "key": "fiscal_info_123456789", "type": "json", "value": "{\"fiscal_date\":\"20042026\",\"fiscal_time\":\"135110\",\"fiscal_number\":\"4000024349\",\"mac\":\"jwKFGE6HiqxxVJw_PhmfR2C74azcXl0urwD-_qHcoWE\",\"display_datetime\":\"20.04.2026 13:51:10\",\"amount\":1500.5,\"links\":{\"external_provider\":\"https://checkbox.in.ua/receipt/123456789\",\"tax_service\":\"https://cabinet.tax.gov.ua/cashregs/check?id=123456789&fn=4000024349&date=20042026&time=135110&sum=1500.50&mac=jwKFGE6HiqxxVJw_PhmfR2C74azcXl0urwD-_qHcoWE\",\"pdf\":\"https://link-to-pdf\",\"png\":\"https://link-to-png\"}}" } ] } ``` #### **Tier 3: Heavy Storage (****`fiscal_storage_{receipt_id}`****)** Dedicated solely to the Base64-encoded strings of the legal XML and visual HTML. * **Key Example:**`fiscal_storage_123456789` * **Type:**`json` * **Value:** ```json { "html": "PHRhYmxlIHN0eWxlPSJ3aWR0...", "xml": "PD94bWwgdmVyc2lvbj0iMS4wI..." } ``` ```json { "metafields": [ { "ownerId": "gid://shopify/Order/1234567890", "namespace": "hutko", "key": "fiscal_storage_123456789", "type": "json", "value": "{\"html\":\"PHRhYmxlIHN0eWxlPSJ3aWR0aDo...\",\"xml\":\"PD94bWwgdmVyc2lvbj0iMS4wI...\"}" } ] } ``` ```graphql mutation SaveFiscalStorage($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { metafields { id namespace key value type } userErrors { field message } } } ``` *** ### **3. Key Benefits** * **Zero Data Loss (Race-Condition Immunity):** If a race condition occurs during the Read-Modify-Write cycle of the `fiscal_receipts` index, only the *pointer* is overwritten. The heavy data (`fiscal_storage_{id}`) and metadata (`fiscal_info_{id}`) are unstructured and appended directly to the order. They will remain safely in the database as "orphaned" keys, fully recoverable via an API scan or Admin app. * **Bypasses Payload Limits:** Shopify caps JSON metafields at 128 KB. By isolating each receipt into its own dynamically keyed metafield, we eliminate the risk of a single order's metafield array bloating past the limit. * **Immutable Timestamps:** Storing the State Tax Service (ДПС) timestamps as strict strings (`20042026`) prevents browsers, Shopify servers, or Liquid engines from accidentally applying timezone shifts, preserving the cryptographic validity of the receipt. * **Native Liquid Rendering:** Storing the complex XML/HTML as Base64 strings avoids breaking JSON structures with unescaped quotes. Shopify’s Liquid engine can decode and render this directly on the frontend using `{{ heavy_data.html | base64_decode }}`. *** ### **4. Risk Points & Caveats to Monitor** * **The 64-Character Key Limit:** Shopify enforces a strict 64-character limit on metafield keys. * *Mitigation:* The prefix `fiscal_storage_` uses 15 characters, leaving **49 characters** for receipt ID. If PRRO provider returns an ID less than 49 characters (standard UUIDs are 36, so this is generally safe). * **Read-Modify-Write Requirement:** Shopify GraphQL cannot "push" to a JSON array. To update `fiscal_receipts`, app must query the existing array, append the new ID in memory, and send the combined array back. * // utils/fiscalParser.js const formatDate = (dateStr) => { if (!dateStr || dateStr.length !== 8) return dateStr; return `${dateStr.slice(0, 2)}.${dateStr.slice(2, 4)}.${dateStr.slice(4)}`; }; const formatTime = (timeStr) => { if (!timeStr || timeStr.length !== 6) return timeStr; return `${timeStr.slice(0, 2)}:${timeStr.slice(2, 4)}:${timeStr.slice(4)}`; }; export const parseFiscalXML = (xmlString) => { const parser = new DOMParser(); const doc = parser.parseFromString(xmlString, "application/xml"); const getText = (parent, selector) => parent?.querySelector(selector)?.textContent || ''; const getElements = (parent, selector) => Array.from(parent?.querySelectorAll(selector) || []); const head = doc.querySelector('CHECKHEAD'); const totals = doc.querySelector('CHECKTOTAL'); return { head: { orgName: getText(head, 'ORGNM'), tin: getText(head, 'TIN'), ipn: getText(head, 'IPN'), pointName: getText(head, 'POINTNM'), pointAddress: getText(head, 'POINTADDR'), date: formatDate(getText(head, 'ORDERDATE')), time: formatTime(getText(head, 'ORDERTIME')), orderNum: getText(head, 'ORDERNUM'), cashDesk: getText(head, 'CASHDESKNUM'), cashRegister: getText(head, 'CASHREGISTERNUM'), offline: getText(head, 'OFFLINE') === 'true', }, totals: { sum: getText(totals, 'SUM'), noRndSum: getText(totals, 'NORNDSUM'), }, payments: getElements(doc, 'CHECKPAY > ROW').map(row => ({ name: getText(row, 'PAYFORMNM'), sum: getText(row, 'SUM'), paysys: getText(row, 'PAYSYS NAME'), acquire: getText(row, 'ACQUIRENM'), cardMask: getText(row, 'EPZDETAILS'), authCode: getText(row, 'AUTHCD') })), taxes: getElements(doc, 'CHECKTAX > ROW').map(row => ({ name: getText(row, 'NAME'), letter: getText(row, 'LETTER'), percent: getText(row, 'PRC'), turnover: getText(row, 'TURNOVER'), sum: getText(row, 'SUM') })), items: getElements(doc, 'CHECKBODY > ROW').map(row => ({ code: getText(row, 'CODE'), name: getText(row, 'NAME'), amount: parseFloat(getText(row, 'AMOUNT')).toString(), unit: getText(row, 'UNITNM'), price: getText(row, 'PRICE'), cost: getText(row, 'COST'), letter: getText(row, 'LETTERS') })) }; }; export const generate58mmReceiptHTML = (receipt) => { return ` Фіскальний Чек
${receipt.head.orgName}
${receipt.head.pointName}
${receipt.head.pointAddress}
ІД: ${receipt.head.tin} / ПН: ${receipt.head.ipn}
${receipt.items.map(item => `
${item.name}
${item.amount} ${item.unit} x ${item.price} ${item.cost} ${item.letter}
`).join('')}
СУМА: ${receipt.totals.sum} грн
${receipt.payments.map(pay => `
${pay.name} ${pay.sum} грн
${pay.cardMask ? `
Картка: ${pay.cardMask}
` : ''} ${pay.authCode ? `
Код авт: ${pay.authCode}
` : ''} ${pay.acquire ? `
Еквайр: ${pay.acquire}
` : ''} `).join('')}
${receipt.taxes.map(tax => `
${tax.letter} ${tax.name} ${tax.percent}% ${tax.sum}
`).join('')}
ЧЕК № ${receipt.head.orderNum} КАСИР: ${receipt.head.cashDesk}
ФН: ${receipt.head.cashRegister} ЗАК: ${receipt.head.offline ? 'ОФЛАЙН' : 'ОНЛАЙН'}
ДАТА: ${receipt.head.date} ЧАС: ${receipt.head.time}
ФІСКАЛЬНИЙ ЧЕК
`; }; // src/Extension.jsx import { useState } from 'preact/hooks'; import { render, BlockStack, Button, Text, Banner } from '@shopify/ui-extensions-preact/admin'; import { parseFiscalXML, generate58mmReceiptHTML } from './utils/fiscalParser'; // Sample XML for testing - replace this with your actual XML data source const SAMPLE_XML = ` 0 0 136FCB26-4D71-4BBD-9E42-D343DDE0132A 43103923 431039210294 ТОВ "СПОРТ АКТИВ ПЛЮС" ТРЕНАЖЕРНИЙ ЗАЛ м. Київ, Голосіївський район, Голосіївський проспект, буд. 30А 20042026 135110 78370 2 4001145600 Оплата 1 false 35.00 0.00 35.00 1 Інтернет еквайринг 35.00 Visa ПУМБ 40316265049 1819 432609XXXXXX9661 981702 0 ПДВ А 20.00 35.00 5.83 101729109 Payment in club: 9 од. 1.000 35.00 А 35.00 `; function PrintReceiptExtension() { const [error, setError] = useState(null); const handlePrintClick = () => { try { setError(null); // 1. Parse XML const receiptData = parseFiscalXML(SAMPLE_XML); // 2. Generate HTML const htmlString = generate58mmReceiptHTML(receiptData); // 3. Mount to blob and open for browser print dialog const blob = new Blob([htmlString], { type: 'text/html' }); const blobUrl = URL.createObjectURL(blob); const newWindow = window.open(blobUrl, '_blank'); if (newWindow) { setTimeout(() => URL.revokeObjectURL(blobUrl), 5000); // Cleanup memory } else { setError('Popup blocker prevented opening the print dialog. Please allow popups.'); } } catch (err) { console.error('XML Parsing or Print Error:', err); setError('Failed to parse or print the fiscal check. Verify the XML format.'); } }; return ( {error && {error}} Ukrainian Fiscal Check Click the button below to generate and print a 58mm receipt. ); } // Ensure the target string matches the `target` in your shopify.extension.toml render('Admin::Product::Details::Render', () => );