16 KiB
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"]
{
"metafields": [
{
"ownerId": "gid://shopify/Order/1234567890",
"namespace": "hutko",
"key": "fiscal_receipts",
"type": "json",
"value": "[\"123456789\",\"987654321\"]"
}
]
}
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:
{ "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 } }mutation SaveFiscalMetadata($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { metafields { id namespace key value type } userErrors { field message } } }{ "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:
{ "html": "PHRhYmxlIHN0eWxlPSJ3aWR0...", "xml": "PD94bWwgdmVyc2lvbj0iMS4wI..." }{ "metafields": [ { "ownerId": "gid://shopify/Order/1234567890", "namespace": "hutko", "key": "fiscal_storage_123456789", "type": "json", "value": "{\"html\":\"PHRhYmxlIHN0eWxlPSJ3aWR0aDo...\",\"xml\":\"PD94bWwgdmVyc2lvbj0iMS4wI...\"}" } ] }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_receiptsindex, 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).
- Mitigation: The prefix
- 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 ` <html lang="uk"> <head> <style> @page { margin: 0; } body { font-family: 'Courier New', Courier, monospace; font-size: 12px; line-height: 1.2; width: 200px; /* 58mm */ margin: 0 auto; padding: 10px 5px; color: #000; } .center { text-align: center; } .left { text-align: left; } .right { text-align: right; } .bold { font-weight: bold; } .divider { border-bottom: 1px dashed #000; margin: 5px 0; } .flex { display: flex; justify-content: space-between; } .item-name { word-break: break-all; margin-bottom: 2px; } .small { font-size: 10px; } </style> </head>
<div class="divider"></div>
${receipt.items.map(item => `
<div class="item-name">${item.name}</div>
<div class="flex">
<span>${item.amount} ${item.unit} x ${item.price}</span>
<span>${item.cost} ${item.letter}</span>
</div>
`).join('')}
<div class="divider"></div>
<div class="flex bold">
<span>СУМА:</span>
<span>${receipt.totals.sum} грн</span>
</div>
<div class="divider"></div>
${receipt.payments.map(pay => `
<div class="flex">
<span>${pay.name}</span>
<span>${pay.sum} грн</span>
</div>
${pay.cardMask ? `<div class="left small">Картка: ${pay.cardMask}</div>` : ''}
${pay.authCode ? `<div class="left small">Код авт: ${pay.authCode}</div>` : ''}
${pay.acquire ? `<div class="left small">Еквайр: ${pay.acquire}</div>` : ''}
`).join('')}
<div class="divider"></div>
${receipt.taxes.map(tax => `
<div class="flex small">
<span>${tax.letter} ${tax.name} ${tax.percent}%</span>
<span>${tax.sum}</span>
</div>
`).join('')}
<div class="divider"></div>
<div class="flex small">
<span>ЧЕК № ${receipt.head.orderNum}</span>
<span>КАСИР: ${receipt.head.cashDesk}</span>
</div>
<div class="flex small">
<span>ФН: ${receipt.head.cashRegister}</span>
<span>ЗАК: ${receipt.head.offline ? 'ОФЛАЙН' : 'ОНЛАЙН'}</span>
</div>
<div class="flex small">
<span>ДАТА: ${receipt.head.date}</span>
<span>ЧАС: ${receipt.head.time}</span>
</div>
<div class="center bold" style="margin-top: 10px;">ФІСКАЛЬНИЙ ЧЕК</div>
<script>
window.onload = function() {
window.print();
}
</script>
</body>
</html>
`; };
// 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 = <CHECK xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <CHECKHEAD> <DOCTYPE>0</DOCTYPE> <DOCSUBTYPE>0</DOCSUBTYPE> <UID>136FCB26-4D71-4BBD-9E42-D343DDE0132A</UID> <TIN>43103923</TIN> <IPN>431039210294</IPN> <ORGNM>ТОВ "СПОРТ АКТИВ ПЛЮС"</ORGNM> <POINTNM>ТРЕНАЖЕРНИЙ ЗАЛ</POINTNM> <POINTADDR>м. Київ, Голосіївський район, Голосіївський проспект, буд. 30А</POINTADDR> <ORDERDATE>20042026</ORDERDATE> <ORDERTIME>135110</ORDERTIME> <ORDERNUM>78370</ORDERNUM> <CASHDESKNUM>2</CASHDESKNUM> <CASHREGISTERNUM>4001145600</CASHREGISTERNUM> <OPERTYPENM>Оплата</OPERTYPENM> <VER>1</VER> <OFFLINE>false</OFFLINE> </CHECKHEAD> <CHECKTOTAL> <SUM>35.00</SUM> <RNDSUM>0.00</RNDSUM> <NORNDSUM>35.00</NORNDSUM> </CHECKTOTAL> <CHECKPAY> <ROW ROWNUM="1"> <PAYFORMCD>1</PAYFORMCD> <PAYFORMNM>Інтернет еквайринг</PAYFORMNM> <SUM>35.00</SUM> <PAYSYS> <ROW ROWNUM="1"> <NAME>Visa</NAME> <ACQUIRENM>ПУМБ</ACQUIRENM> <ACQUIRETRANSID>40316265049</ACQUIRETRANSID> <DEVICEID>1819</DEVICEID> <EPZDETAILS>432609XXXXXX9661</EPZDETAILS> <AUTHCD>981702</AUTHCD> </ROW> </PAYSYS> </ROW> </CHECKPAY> <CHECKTAX> <ROW ROWNUM="1"> <TYPE>0</TYPE> <NAME>ПДВ</NAME> <LETTER>А</LETTER> <PRC>20.00</PRC> <TURNOVER>35.00</TURNOVER> <SUM>5.83</SUM> </ROW> </CHECKTAX> <CHECKBODY> <ROW ROWNUM="1"> <CODE>101729109</CODE> <NAME>Payment in club: 9</NAME> <UNITNM>од.</UNITNM> <AMOUNT>1.000</AMOUNT> <PRICE>35.00</PRICE> <LETTERS>А</LETTERS> <COST>35.00</COST> </ROW> </CHECKBODY> </CHECK>;
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.
<Button onClick={handlePrintClick}>
Print Fiscal Check
</Button>
</BlockStack>
); }
// Ensure the target string matches the target in your shopify.extension.toml
render('Admin::Product::Details::Render', () => );