first commit

This commit is contained in:
O K
2026-05-20 17:32:11 +03:00
commit c92aee86a2
33 changed files with 11584 additions and 0 deletions

475
gemini.md Normal file
View File

@@ -0,0 +1,475 @@
# 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 Orders 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. Shopifys 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 />);