476 lines
16 KiB
Markdown
476 lines
16 KiB
Markdown
# 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 `
|
||
<!DOCTYPE html>
|
||
<html lang="uk">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Фіскальний Чек</title>
|
||
<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>
|
||
<body>
|
||
<div class="center bold">${receipt.head.orgName}</div>
|
||
<div class="center">${receipt.head.pointName}</div>
|
||
<div class="center small">${receipt.head.pointAddress}</div>
|
||
<div class="center small">ІД: ${receipt.head.tin} / ПН: ${receipt.head.ipn}</div>
|
||
|
||
<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 (
|
||
<BlockStack gap="base">
|
||
{error && <Banner status="critical">{error}</Banner>}
|
||
<Text fontWeight="bold">Ukrainian Fiscal Check</Text>
|
||
<Text>Click the button below to generate and print a 58mm receipt.</Text>
|
||
|
||
<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', () => <PrintReceiptExtension />);
|
||
|
||
|
||
|