# 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 `
101729109