Custom Fields · Merchant Extensibility

JSON column + CustomFieldDefinition — per-entity, per-merchant, unlimited, typed, indexable, offline-ready

Creado: 21 abril 2026  |  Última revisión: 21 abril 2026  |  Fuente: Qiiub research doc + design doc §4 + open questions #09

Tabla de contenido

2
Nuevos artefactos schema
<2 µs
Read overhead vs real column
Campos por entidad
5
Tipos soportados
15%
~Implementado hoy
Offline-compatible

Resumen ejecutivo

Cada merchant quiere "una columna más" en Product, Customer o Sale. Una farmacia necesita ExpirationDate, NDC, DEASchedule. Una ferretería necesita ThreadSize, Material, WarrantyMonths. RMS/RMH resolvió esto con columnas pre-alocadas + tabla de labels — funcional pero con techo duro de 3 o 15 campos por entidad. Qiiub elige el patrón moderno (Shopify Metafields, Square Custom Attributes, NetSuite): una columna JSON por entidad con los valores + una tabla CustomFieldDefinition por merchant que describe key, tipo, validación, y si es searchable.

Lo searchable se resuelve con computed columns persistidos (SQL Server) o generated columns stored (SQLite), ambas con índices. Performance queda indistinguible de columnas reales. Un POS terminal ejecuta posupdate.exe para aplicar ALTER TABLE aditivos cuando un merchant agrega un nuevo campo searchable — alineado con la política additive-only existente.

Decisión cerrada en product-module-decisions.md #09, research completo en research-custom-fields-extensibility.md, diseño en config-extensibility.md §4.

Contexto 1. El problema que resuelve

Cuatro restricciones simultáneas que descartan los approaches obvios:

  1. Campos arbitrarios por merchant por entidad — cada vertical tiene requisitos propios. Un schema compartido no puede crecer por cada uno.
  2. Performance de lectura en POS — el barcode scan debe completar en <5 ms incluyendo cualquier custom field que acompañe el producto.
  3. Indexable para búsqueda — "dame todos los productos con NDC que expiran este mes" tiene que correr rápido.
  4. Offline-compatible — los POS terminales usan SQLite; el approach debe replicarse.

Por qué los tres approaches "obvios" no funcionan solos

ApproachProsContrasVeredicto
Wide Table (RMH/RMS)
CustomText1-5, CustomDate1-5, CustomNumber1-5 + CustomCaption
Reads rapidísimos · Validación implícita por tipo de columna Techo duro (3 o 15 campos) · Wasted space en NULLs · Label indirection via Style int enum ✗ Limitado
EAV
Tabla (EntityId, AttributeId, Value)
Unlimited · Zero schema change JOIN/PIVOT en cada read · Multi-row writes · Offline payload explota ✗ Lento en POS
JSON column + Definition (Qiiub elegido) Unlimited · Single read · Indexable vía computed columns · Offline compatible · Pattern de Shopify/NetSuite Writes reescriben blob completo (irrelevante a volumen POS) · Requiere SQL 2022+/SQLite 3.38+ ✓ Cumple las 4 restricciones

Decisión 2. JSON column + CustomFieldDefinition

Cada entidad extendible (Product, Customer, Sale, Employee) gana una columna CustomFields nvarchar(max) DEFAULT '{}' con CHECK (ISJSON(CustomFields) = 1). Una tabla nueva CustomFieldDefinition describe, per-merchant, qué campos existen para cada EntityType. Los campos marcados IsSearchable = true materializan un computed/generated column indexable.

Semántica clave: el FieldKey en CustomFieldDefinition es el key usado dentro del JSON. La definición describe el shape; la columna JSON contiene los valores. Ambos llegan inline con la entidad al POS terminal.

Path de migración a native json type

Hoy: nvarchar(max) + ISJSON (compatible SQL 2022 + Azure SQL DB + SQLite). Cuando todos los environments soporten native json (SQL Server 2025 / Azure SQL DB current), migración no-breaking via ALTER COLUMN CustomFields json NOT NULL. Storage ~15% más compacto, parsing más rápido, dedicated JSON indexes (reemplazan computed columns).

Schema 3. ERD y DDL

Product (ya existe)
PK Id
MerchantId
Name, Description...
+ CustomFields nvarchar(max)
    CHECK (ISJSON(...))
Definitions describen
los keys del JSON
CustomFieldDefinition (nueva)
PK Id
MerchantId
EntityType (Product, Customer, ...)
FieldKey (snake_case)
FieldName (display label)
DataType (string/number/date/bool/select)
IsRequired, IsSearchable
Options JSON, for select
DefaultValue, Module, SortOrder
UQ (MerchantId, EntityType, FieldKey)

Computed/Generated column para IsSearchable

Al marcar IsSearchable=true, el sistema agrega:

-- SQL Server (cloud):
ALTER TABLE Product ADD CustomField_ExpirationDate
    AS CAST(JSON_VALUE(CustomFields, '$.expiration_date') AS date) PERSISTED;

CREATE NONCLUSTERED INDEX IX_Product_CF_ExpirationDate
    ON Product(MerchantId, CustomField_ExpirationDate)
    WHERE CustomField_ExpirationDate IS NOT NULL;

-- SQLite (POS terminal — applied by posupdate.exe):
ALTER TABLE Product ADD COLUMN CustomField_ExpirationDate
    AS (json_extract(CustomFields, '$.expiration_date')) STORED;

CREATE INDEX IX_Product_CF_ExpirationDate
    ON Product(CustomField_ExpirationDate);

Flow 4. Ciclo de vida completo

1. Merchant admin
define CustomFieldDefinition
2. API valida
FieldKey único por entidad
3. Cloud INSERT
si IsSearchable: ALTER TABLE + CREATE INDEX
4. Sync pull
terminal baja CustomFieldDefinition
5. posupdate.exe
aplica ALTER + CREATE INDEX en SQLite
6. Admin edita Product
UI renderiza campos dinámicos según Definitions
7. API valida
types, required, options
8. UPDATE Product
CustomFields = nuevo JSON
9. POS scan barcode
SELECT * FROM Product WHERE Sku=@x
10. Return row completo
CustomFields JSON inline, <1ms
11. Display NDC + Expiration
json_extract on demand

Mockup 5. UI admin portal

5.1 Definir un custom field

⚙ Settings → Custom Fields → Product → Nuevo campo
Usado internamente como key en el JSON. Immutable una vez creado.
Mostrado al usuario en formularios y reportes.
Si está seteado, el campo solo aparece cuando el módulo está activo para este merchant.
Rechaza saves donde este campo esté vacío
Genera computed column + index. Útil para filtros frecuentes. Límite: 10 searchable per entidad.

5.2 Editar un Product con custom fields

📦 Product → Tylenol 500mg × 100ct → Tab "Compliance" (campos de pharmacy)

Performance 6. Queries típicas

6.1 Barcode scan (POS hot path)

SELECT * FROM Product WHERE MerchantId = @m AND Sku = @sku;
-- 1 row read, CustomFields JSON inline, <1ms

6.2 Display en UI (extract específico)

-- SQL Server
SELECT
    Name,
    JSON_VALUE(CustomFields, '$.ndc_number') AS Ndc,
    JSON_VALUE(CustomFields, '$.expiration_date') AS Expiration
FROM Product WHERE Id = @id;

-- SQLite (POS terminal)
SELECT
    Name,
    json_extract(CustomFields, '$.ndc_number') AS Ndc,
    json_extract(CustomFields, '$.expiration_date') AS Expiration
FROM Product WHERE Id = @id;

6.3 Búsqueda indexed (reporting, alerts)

-- Productos que expiran este mes — usa el index
SELECT Id, Name, CustomField_ExpirationDate
FROM Product
WHERE MerchantId = @m
  AND CustomField_ExpirationDate BETWEEN @start AND @end
ORDER BY CustomField_ExpirationDate;
-- Performance idéntica a una columna real indexed

6.4 Bulk update (merchant admin)

UPDATE Product
SET CustomFields = JSON_MODIFY(CustomFields, '$.expiration_date', '2027-12-31')
WHERE MerchantId = @m AND CategoryId = @rxCategoryId;

Ejemplos 7. Custom fields por vertical

🧪 Farmacia (Qiiub Rx)
🔧 Ferretería (Qiiub Build)
🛒 Supermercado (Qiiub Shop)
-- CustomFieldDefinition rows (seeded por módulo pharmacy.rx):
{ EntityType: "Product", FieldKey: "ndc_number",     DataType: "string",  IsRequired: true,  Module: "pharmacy" }
{ EntityType: "Product", FieldKey: "expiration_date", DataType: "date",    IsSearchable: true, Module: "pharmacy" }
{ EntityType: "Product", FieldKey: "lot_number",     DataType: "string",  Module: "pharmacy" }
{ EntityType: "Product", FieldKey: "dea_schedule",   DataType: "select",  Options: '["CII","CIII","CIV","CV"]', Module: "pharmacy.controlled" }

-- Product row con estos custom fields:
{
  "Name": "Oxycodone 5mg",
  "CustomFields": {
    "ndc_number": "12345-678-90",
    "expiration_date": "2027-06-15",
    "lot_number": "LOT-A7X-2026",
    "dea_schedule": "CII"
  }
}
-- CustomFieldDefinition rows:
{ EntityType: "Product", FieldKey: "thread_size",     DataType: "string", IsSearchable: true }
{ EntityType: "Product", FieldKey: "pipe_schedule",   DataType: "select", Options: '["SCH 40","SCH 80","SCH 160"]' }
{ EntityType: "Product", FieldKey: "material",        DataType: "select", Options: '["PVC","Acero","Cobre","Bronce"]' }
{ EntityType: "Product", FieldKey: "warranty_months", DataType: "number" }

-- Product row:
{
  "Name": "Tubo PVC 1/2" x 10ft",
  "CustomFields": {
    "thread_size": "1/2-NPT",
    "pipe_schedule": "SCH 40",
    "material": "PVC",
    "warranty_months": 24
  }
}
-- CustomFieldDefinition rows:
{ EntityType: "Product", FieldKey: "batch_number",      DataType: "string" }
{ EntityType: "Product", FieldKey: "origin_country",    DataType: "string" }
{ EntityType: "Product", FieldKey: "organic_certified", DataType: "boolean" }
{ EntityType: "Product", FieldKey: "allergen_warnings", DataType: "string" }

-- Product row:
{
  "Name": "Cereal Integral 500g",
  "CustomFields": {
    "batch_number": "B-2026-0421-A",
    "origin_country": "PR",
    "organic_certified": true,
    "allergen_warnings": "Gluten, Lácteos"
  }
}

Offline 8. Compatibilidad Cloud ↔ SQLite

El diseño funciona idénticamente en cloud y en cada POS terminal. Las funciones JSON difieren por nombre pero la semántica es la misma.

OperaciónSQL Server (cloud)SQLite (POS terminal)
Extract valueJSON_VALUE(CustomFields, '$.x')json_extract(CustomFields, '$.x')
Validate JSONISJSON(CustomFields) = 1json_valid(CustomFields)
Persisted computed columnAS JSON_VALUE(...) PERSISTEDAS json_extract(...) STORED
Check constraintCHECK (ISJSON(...) = 1)CHECK (json_valid(...))
Modify JSON fieldJSON_MODIFY(...)json_set(...)

Sync strategy

  1. Pull syncCustomFieldDefinition rows bajan como cualquier otra config (watermark por UpdatedAtUtc). Product.CustomFields JSON viaja inline con el Product row, sin payload adicional.
  2. Schema change en terminal — cuando llega un CustomFieldDefinition(IsSearchable=true) nuevo, posupdate.exe aplica ALTER TABLE ... ADD ... STORED + CREATE INDEX. Additive-only, alineado con §5.4 del design doc.
  3. POS queries — barcode scan lee row completo (JSON inline), display extrae específico vía json_extract, búsqueda usa el generated column.
Caso farmacia: NDC + expiration date + lot number funcionan offline. La farmacia puede dispensar sin conexión con todos los campos clínicos disponibles en el POS terminal.

Industria 9. Qué hacen los POS modernos

PlataformaModeloDefinitionsTiposSearchable
Shopify Metafields (gold standard)JSON inline + definition✓ MetafieldDefinition~20 base + arrays
SquareEAV + definition✓ CustomAttributeDefinition4 base
BigCommerceHíbrido (custom fields + metafields)ParcialLooseParcial
CloverPropiedades key-value sueltasLooseParcial
LightspeedWide table with labelsLabels per merchant4 base
NetSuiteHíbrido + formula engine✓ RichExtensive + formulas
RMS/RMHWide table + CustomCaptionGlobal captions3 (text/num/date)
QIIUB (propuesto)JSON column + definition✓ CustomFieldDefinition5 (string/number/date/bool/select)✓ via computed columns
Tendencia clara: las plataformas que reconstruyeron su capa de extensibilidad en la última década usan el patrón de definitions + typed values. Qiiub se alinea con Shopify Metafields pero adaptado a POS (computed columns para mantener barcode-scan latency).

Migración 10. RMH → Qiiub

RMS/RMHQiiubNotas
Item.SubDescription1/2/3 (3 text slots) Product.CustomFields con keys sub_description_1/2/3 + CustomFieldDefinition rows Migration: convertir 3 columnas a 3 keys en JSON
Customer.CustomText1-5 + CustomNumber1-5 + CustomDate1-5 (15 slots) Customer.CustomFields con keys configurables + CustomFieldDefinition rows (DataType según slot type) Límite artificial de 15 eliminado
Supplier.CustomText1-5 + CustomNumber1-5 + CustomDate1-5 Supplier.CustomFields (si se agrega al módulo) Misma estrategia
CustomCaption (ID, HQID, Style int, Caption nvarchar) CustomFieldDefinition (FieldKey, FieldName, DataType, IsRequired, IsSearchable, Options, ...) Migración: cada CustomCaption row → 1 CustomFieldDefinition con FieldKey derivado del Style + FieldName = Caption

Ventajas sobre RMH

Roadmap 11. Future enhancements (diferidos)

#EnhancementUse case
1Multi-value fields (arrays)Tags, color swatches, certificaciones múltiples
2Field versioning / renameRenombrar FieldKey sin perder data
3Validation rules avanzadasMinValue/MaxValue, regex, date ranges
4Display groupingAgrupar "Regulatory" / "Marketing" / "Technical"
5Field-level access controlSolo Managers pueden ver cost notes
6Computed / derived fieldsprofit_margin = formula(price, cost)
7Category-scoped definitionsTodos los Rx products requieren NDC
8Vertical field templatesActivar pharmacy auto-crea NDC/DEA/Expiration
9Native JSON data type migration~15% storage reduction en SQL Server 2025
10Configurable searchable limitMerchant con muchos filter-on fields
11Bilingual labels (opt-in)Merchants bilingües quieren ambos idiomas

Detalle completo con prerequisites en docs/research/research-custom-fields-extensibility.md §8.

Referencias