{"title":"Surface a custom object on the website and in the POS","slug":"surface-custom-object-in-liquid","url":"https://support.storeconnect.com/articles/surface-custom-object-in-liquid","url_markdown":"https://support.storeconnect.com/articles/surface-custom-object-in-liquid.md","subtitle":null,"summary":"Take a Salesforce custom object related to Contact onto both the storefront and the POS, using a worked example of a city letting residents pay utility, tax and parking bills online or at a counter, and covering why each surface reads the data by a different mechanism.","type":"Developer_Documentation","video_url":"","keywords":"custom object, custom data mapping, sync flow, contact lookup, liquid query, custom_data, pos layout field, indexeddb, pos custom data, public sector, bill payment, clerk, counter payment, cart add product, unit price, internal liquid error","last_modified":"2026-09-15T02:32:04+0000","body_markdown":"## Overview\n\nUse this process to show records of your own custom object, related to a\nContact, on your storefront and in the POS. It covers the Salesforce setup both\nsurfaces share, then the website and the POS in turn. The two surfaces read\ncustom data by different mechanisms, so a design that works on one does not\ntransfer to the other.\n\nThe worked example is a city or county that holds resident account data in\nSalesforce (utility bills, property tax, parking fines) and needs it in two\nplaces:\n\n- On the website, so a resident can sign in, see what they owe and pay it themselves.\n- In the POS, so a counter clerk can look up a resident by their bill number, see the same balances, and take payment in person.\n\nThe example builds both from one custom object, `Citizen_Bill__c`, with a\n`Contact__c` lookup to the resident. Substitute your own object and field names.\n\n| | Website | POS |\n|---|---|---|\n| Where the data lives | Your store's database | A local database on the device |\n| How Liquid reads it | `{% query %}` against the object | JavaScript reading the device's IndexedDB |\n| Can it read a custom object directly? | Yes | No, see Part 3 |\n| What makes a field available | A **Custom Data Mapping** | A **Custom Data Mapping** and a **POS Layout Field** |\n| When new data appears | Next request | Next device resync |\n\n:::warning\nA POS view cannot run `{% query %}`. The device holds a fixed set of columns per\nobject and works offline from its own local database, so it cannot reach out for\narbitrary records. Part 3 covers what to do instead.\n:::\n\n## Before you start\n\n- You have permission to create custom fields, **Custom Data Mapping** records, **POS Layout** records, and record-triggered Flows.\n- The [StoreConnect sync user](how-to-create-a-storeconnect-sync-user) has read access to every object and field involved, through its profile or a permission set. A field the sync user cannot see behaves exactly like a field that was never mapped: the value never reaches the storefront or the device, and nothing reports an error.\n- You know how [custom data mappings](liquid-custom-data-fields) expose a field to Liquid, and how [record-triggered Flows sync a custom object](sync-custom-objects-using-flows).\n- For Part 3, you have a POS register you can open and resync, and you know how [POS layouts](pos-layouts) work.\n- You have a storefront template you can edit for the verification steps.\n\n## Part 1: The Salesforce foundation\n\nBoth surfaces depend on this, so do it once and confirm it before building either.\n\n### Step 1: Map the fields\n\n1.  Go to the **Custom Data Mappings** list.\n2.  Create one **Custom Data Mapping** per field you need to read, using the values below. Type the object and field names in lowercase.\n3.  Save. **Data Type** is filled in for you from the Salesforce field type.\n\n| Field | Value |\n|-------|-------|\n| **Object API Name** | `citizen_bill__c` |\n| **Field API Name** | `amount_due__c` |\n| **Access Level** | `read` |\n\nThe worked example uses six mappings: `amount_due__c`, `bill_type__c`,\n`contact__c`, `due_date__c`, `reference__c` and `status__c`.\n\nObject names resolve case-insensitively, so lowercase is not strictly required\nhere. It is required on **POS Layout Fields** in Part 3, and keeping one convention\navoids a mismatch later.\n\n:::tip\n`Name` needs no mapping. It arrives as a first-class attribute on every row, so\n`{{ bill.name }}` works with no mapping for it. Map only your own fields.\n:::\n\n### Step 2: Build the sync Flows\n\nA **Custom Data Mapping** registers the schema and moves no data. Two\nrecord-triggered Flows do the moving, and without them every query returns zero\nrows with no error anywhere.\n\n1.  Build the create-and-update Flow and the delete Flow on `Citizen_Bill__c` exactly as described in [Sync custom objects using flows](sync-custom-objects-using-flows).\n2.  In the create-and-update Flow, keep the decision on `ISNEW()` so that a new record calls **StoreConnect: Sync Record Changes** with **Change Type** `Create` and an existing record calls it with `Update`. Later steps in this article extend that decision.\n3.  Activate both Flows before you create any test records.\n\n:::warning\nAn `Update` for a row your store has never received does not apply, and the\nrecovery is not immediate. The store cannot find the row, so instead of writing\nit the store requests a full refresh of that record from Salesforce. The row does\narrive eventually, but by way of a round trip out to Salesforce and back rather\nthan a local retry, so it is not there when the update returns. Repeated updates\nqueue repeated refresh requests, which are not deduplicated.\n\nThat is why the decision matters rather than always sending `Update`, and why\nrecords created *before* you activated the Flow are not reliably fixed by editing\nand saving them. Force the create branch instead, or recreate the records.\n:::\n\n### Step 3: Decide how you will handle undelete\n\nThere is no third Flow, because a record-triggered Flow cannot fire on undelete.\nThe platform offers only three record trigger types, `RecordBeforeSave`,\n`RecordAfterSave` and `RecordBeforeDelete`, and the record trigger types are\n`Create`, `Update`, `CreateAndUpdate` and `Delete`. Undelete appears in neither\nlist.\n\nDelete a bill and the delete Flow removes it from your store. Restore it from the\nRecycle Bin and nothing tells your store it is back, so it stays missing from the\nwebsite and the POS. Editing and saving the restored record does not fix it: that\nfires the update branch, and an update for a row the store no longer has only\nqueues a refresh request.\n\nThe store also refuses to re-insert a record it has seen deleted. It keeps a\nrecord of every delete it has processed for a retention period, 7 days by\ndefault, and skips the insert of any record whose Id matches one of them, with no\nerror and no log entry. Salesforce restores a record under its original Id, so a\nrestored record is refused until that period passes. The Recycle Bin holds a\ndeleted record for 15 days, so for the first week a restored record cannot be\nsynced under its own Id. The retention period is a StoreConnect setting for your\nstore, not a setting in your Salesforce org, so confirm it with StoreConnect\nsupport before relying on the window.\n\n:::warning\nThere is no way around the refusal from the Salesforce side. Requesting a\nrefresh reaches the same insert path, and editing the record produces an update\nthat finds no row and asks for a refresh. Sending `Create` again does not help.\n:::\n\nRecreate the record instead. A new record gets a new Id, so it does not match\nthe recorded delete and it inserts normally. Where the restored record has to\nkeep its Id for Salesforce reasons, the store copy is unavailable until the\nretention period passes, so tell the people affected rather than leaving them to\ndiscover it.\n\nThe create path does work once the retention period has passed, or on a record\nwhose delete never synced. There are two ways to send it.\n\n**With Apex:** an `after undelete` trigger that calls the\n**StoreConnect: Sync Record Changes** action with **Change Type** `Create`.\n\n**Without Apex:** add a checkbox to the object, for example\n`Force_Store_Sync__c`, and extend the decision in the create-and-update Flow so\nthe `Create` outcome is taken when the record is new or the checkbox is set:\n\n| Outcome condition | **Change Type** |\n|-------------------|-----------------|\n| `ISNEW()` is true, or `Force_Store_Sync__c` is true | `Create` |\n| Otherwise | `Update` |\n\nAn admin then ticks the box and saves, which fires the after-save Flow on update\nand takes the `Create` outcome. Clear the checkbox afterwards.\n\n:::note\nStoreConnect's own managed objects are not a model here. The package does not\nsync an undelete either, so there is no built-in pattern to copy.\n:::\n\n:::tip\nRecords created before you activated the create-and-update Flow are a different\nand simpler case. They were never sent and were never deleted, so no guard\napplies. The create path works on them, via the flag or by recreating them.\n:::\n\n### Step 4: Confirm the rows arrived\n\nBefore writing real template logic, prove the data is there. Add this temporarily\nto any storefront template:\n\n\n```liquid\n\n{%- query 'citizen_bill__c' as bills -%}\nCOUNT: {{ bills.size }}\n{%- for bill in bills limit: 1 -%}\n  {{ bill | json }}\n{%- endfor -%}\n```\n\n\nA count of zero means the Flows are not firing, so go back to Step 2. A count\nmatching Salesforce means the sync works.\n\nThe `json` output shows the row shape:\n\n```json\n\n{\"citizen_bill__c\": {\n  \"name\": \"BILL-00010\",\n  \"sfid\": \"a4tQE00000ifCzxYAE\",\n  \"object_name\": \"citizen_bill__c\",\n  \"createddate\": null,\n  \"systemmodstamp\": null,\n  \"custom_data\": {\n    \"reference__c\": \"ELEC-2026-08-BJ\", \"bill_type__c\": \"Electric Bill\",\n    \"amount_due__c\": \"58.00\", \"due_date__c\": \"2026-09-15\",\n    \"status__c\": \"Open\", \"contact__c\": \"003QE000006RFCQYA4\"\n  },\n  \"reference__c\": \"ELEC-2026-08-BJ\", \"bill_type__c\": \"Electric Bill\",\n  \"amount_due__c\": \"58.00\", \"due_date__c\": \"2026-09-15\",\n  \"status__c\": \"Open\", \"contact__c\": \"003QE000006RFCQYA4\"\n}}\n```\n\nThree things to take from it:\n\n- Your fields sit directly on the row, so `{{ bill.amount_due__c }}` is the accessor to use. They also appear under `custom_data`, which is the longer equivalent.\n- `sfid` carries the Salesforce record Id.\n- `createddate` and `systemmodstamp` arrive empty. Do not build on them.\n\n:::warning\n`bill.data.amount_due__c` renders blank. `data` is the accessor for custom\n*fields on StoreConnect's own objects* such as Contact and Product2. A custom\n*object* you sync yourself uses `custom_data`, or the field name directly. A\nmissing attribute on a Drop renders with no error text, so this reads exactly\nlike a sync that never happened and sends you back to the Flows for nothing.\n:::\n\n## Part 2: The website\n\n### Which query conditions the object supports\n\n`query` adds no scope of its own, and whether a condition works depends on the\n**Data Type** of the field's **Custom Data Mapping**, not on whether the field is a\nlookup. The mapping's data type mirrors the Salesforce display type, and only some\nof those types are implemented as query conditions.\n\n| | Data types |\n|---|---|\n| **Filterable** | `string`, `email`, `textarea`, `url`, `phone`, `reference` (lookup), `integer`, `double`, `boolean`, `date`, `datetime`, `time` |\n| **Raises** | `picklist`, `multipicklist`, `currency`, `percent`, `long`, `combobox`, `address`, `location`, `base64`, `anytype`, `encryptedstring`, `datacategorygroupreference`, `id` |\n\nThe first five of the unsupported types are the ones you are likely to hit. A\npicklist status field and a currency amount are both common on a billing object,\nand neither can be filtered in the query.\n\n:::warning\nThe error you get is misleading. An unsupported type raises\n`Liquid error (line N): internal`, which names neither the field nor the reason.\nThe underlying message identifies the value rather than the type, so the page\ngives you nothing to work from. If a condition raises `internal`, check the\nmapping's **Data Type** against the table above before looking anywhere else.\n:::\n\nMeasured on a `citizen_bill__c` with six `read` mappings, on platform 21.7.0.1:\n\n| What you write | Result |\n|----------------|--------|\n| `{%- query 'citizen_bill__c' as bills -%}` | Returns every synced row |\n| `contact__c: '003QE000006RFCQYA4'` | Filters correctly. The lookup field works as a bare condition, and holds the Salesforce Id |\n| `contact__c: current_customer.id` | Matches nothing. The lookup column holds the Salesforce Id, and a drop's `id` is the storefront's own UUID |\n| `status__c: 'Open'` | Raised `Liquid error (line N): internal`, because a picklist is not supported |\n| `data.status__c: 'Open'` | Raised the same error, and the prefix is not the documented form here |\n| `due_date__c: '2026-09-01'` | Filters correctly, because a date is supported |\n\n`order by` on a custom object accepts only the row's built-in columns, and of\nthose only `name` sorts usefully, because `createddate` and `systemmodstamp`\narrive empty. A mapped custom field is not a column, so\n`order by 'due_date__c asc'` raises `Invalid order clause: unknown field`. Sort\nin Liquid instead.\n\n### Filter by the signed-in contact\n\nThe lookup column holds the Salesforce Id, and the Contact drop does not expose\none: `current_customer` provides `id`, which is the storefront's own UUID. So the\nsigned-in contact cannot be matched against the lookup directly.\n\nAdd a formula field on the child object surfacing the parent's\n`s_c__sC_Id__c`, StoreConnect's own external Id, which equals the drop's `id`.\nThe examples below call it `contact_scid__c`. Give it a **Custom Data Mapping** of its\nown, the same as any other field you query, then filter `current_customer.id`\nagainst it.\n\nThis is the approach to use. It needs no Salesforce Ids in the template, and it\nrelies only on documented drop attributes.\n\n:::note\nA formula field needs its own **Custom Data Mapping** before it can be queried, or the\ntag raises `Invalid liquid query field`. The\n[custom data fields article](liquid-custom-data-fields) advises against mapping\nformula fields because their values can change. This one is safe: a parent's\n`s_c__sC_Id__c` never changes, so the formula result is stable.\n:::\n\n### A resident paying their own bills\n\nQuery on the Contact lookup, then filter and total in Liquid.\n\n\n```liquid\n\n{%- comment -%}\n  current_customer is nil for an anonymous visitor. Without this guard the\n  condition filters on an empty value, and every row in the store becomes a\n  candidate -- one resident's bills shown to another.\n{%- endcomment -%}\n{%- unless current_customer -%}\n  \u003cp\u003ePlease sign in to see your account.\u003c/p\u003e\n{%- else -%}\n\n{%- query 'citizen_bill__c' as bills, contact_scid__c: current_customer.id -%}\n\n{%- assign owed = 0 -%}\n{%- assign open_count = 0 -%}\n{%- for bill in bills -%}\n  {%- if bill.status__c == 'Open' -%}\n    {%- assign owed = owed | plus: bill.amount_due__c -%}\n    {%- assign open_count = open_count | plus: 1 -%}\n  {%- endif -%}\n{%- endfor -%}\n\n{%- if open_count \u003e 0 -%}\n  \u003cp\u003eYou have {{ open_count }} outstanding\n    {%- if open_count == 1 %} bill{% else %} bills{% endif %},\n    totaling {{ owed | money }}.\u003c/p\u003e\n\n  \u003ctable\u003e\n    \u003cthead\u003e\n      \u003ctr\u003e\u003cth\u003eReference\u003c/th\u003e\u003cth\u003eType\u003c/th\u003e\u003cth\u003eDue\u003c/th\u003e\u003cth\u003eAmount\u003c/th\u003e\u003c/tr\u003e\n    \u003c/thead\u003e\n    \u003ctbody\u003e\n      {%- for bill in bills -%}\n        {%- if bill.status__c == 'Open' -%}\n          \u003ctr\u003e\n            \u003ctd\u003e{{ bill.reference__c }}\u003c/td\u003e\n            \u003ctd\u003e{{ bill.bill_type__c }}\u003c/td\u003e\n            \u003ctd\u003e{{ bill.due_date__c | date: '%d %b %Y' }}\u003c/td\u003e\n            \u003ctd\u003e{{ bill.amount_due__c | money }}\u003c/td\u003e\n          \u003c/tr\u003e\n        {%- endif -%}\n      {%- endfor -%}\n    \u003c/tbody\u003e\n  \u003c/table\u003e\n{%- else -%}\n  \u003cp\u003eYou have no outstanding bills.\u003c/p\u003e\n{%- endif -%}\n\n{%- endunless -%}\n```\n\n\nA signed-in resident with open bills now sees the count, the total owed, and one\nrow per open bill. A resident with none sees the \"no outstanding bills\" message,\nand an anonymous visitor sees the sign-in prompt.\n\n`current_customer` is itself a Contact drop, so there is no separate customer\nobject and no `current_customer.contact`.\n\nThe two Ids are different values, and neither is interchangeable.\n`current_customer.id` returns StoreConnect's own external Id, an sc_id UUID, not\nan 18-character Salesforce Id. A synced custom object's lookup column holds the\nSalesforce Id:\n\n```\ncurrent_customer.id       = 1d3c61c7-ef9c-4868-8848-27389692b531\ncontact__c column value   = 003QE000006RFCQYA4\n```\n\nFiltering the lookup with `current_customer.id` therefore matches nothing, and\nbecause an empty result is a legitimate answer the page renders \"no outstanding\nbills\" for every resident rather than reporting a problem. Use the formula field\ndescribed above, which compares like with like.\n\n:::warning\nThere is no `sfid` on the Contact drop, and reaching for one fails loudly rather\nthan quietly. Templates render with strict variables, so `current_customer.sfid`\nraises `Liquid error (line N): undefined method sfid` at that point and the\nassignment on that line never happens, leaving the query variable empty and any\nloop below it iterating nothing. A queried Contact record does expose `sfid`,\nbecause a record read through `query` carries its own columns:\n\n\n```liquid\n\n{%- query 'contact' as me, s_c__sc_id__c: current_customer.id -%}\n{%- for c in me -%}{{ c.sfid }}{%- endfor -%}\n```\n\n\nThat works, but it costs a second query on every render. The formula field above\nis the better route.\n:::\n\n`plus` coerces the stored string to a number, so no cast is needed before adding\n`amount_due__c` values.\n\n:::warning\nThe Contact condition is the only thing scoping this query to one person.\n`query` applies no store or customer scope of its own, so a missing or empty\ncondition returns other residents' rows. Guard the anonymous case, as above, and\nnever widen the query to make it return something while debugging. If your object\nalso carries a store field, add that condition too.\n:::\n\n:::tip\nEvery `query` tag is a database call. Run one query for the resident's rows and\nreuse the result, as above, rather than querying again inside the loop.\n:::\n\n## Part 3: The POS\n\n### Why the website approach does not transfer\n\nA POS device works offline from a local database and holds a fixed set of columns\nper object, so a POS view cannot run a `query` and cannot reach a custom object's\nrows the way a storefront template can.\n\nRecords of a custom object reach a device only through a **POS Layout** for that\nobject, with a **POS Layout Field** per column, picked up on the next resync.\n\n:::warning\nA **POS Layout** whose identifier is not one of the\n[system layout identifiers](pos-layouts) is not placed on a POS screen by itself.\nIts records sync to the device, but they display only when an action item opens\nthe layout with `nav:layout` or `open:layout`.\n:::\n\n### The pattern that works: project onto Contact\n\nContact is already synced to every device. So instead of trying to get\n`Citizen_Bill__c` onto a POS screen, put the numbers a clerk needs onto the\nresident's Contact record, and let the POS read them there.\n\nFor the city example, seven custom fields on Contact:\n\n| Field | Type | Purpose |\n|-------|------|---------|\n| `bill_customer_number__c` | Text | What the resident quotes at the counter |\n| `outstanding_balance__c` | Currency | Total owed, for the summary line |\n| `property_tax_balance__c` | Currency | Balance by category |\n| `electric_bill_balance__c` | Currency | Balance by category |\n| `water_bill_balance__c` | Currency | Balance by category |\n| `parking_fines_balance__c` | Currency | Balance by category |\n| `bills_json__c` | Long Text Area | Itemized bills as JSON, so each keeps its own reference and due date |\n\nKeep these in step with `Citizen_Bill__c` using a record-triggered Flow on the\ncustom object that rolls the balances up onto the parent Contact. The custom\nobject stays the system of record; the Contact fields are a projection of it for\nthe counter.\n\n:::tip\nThe JSON field is what makes this scale. A device column per bill is not\npossible, so scalar columns carry the summary a clerk scans, and one Long Text\nArea carries the itemized detail to parse in the view. Note a Long Text Area\ncannot be filtered in SOQL, so never make it the field you search on.\n:::\n\n### Step 1: Create the POS Layout and its fields, in this order\n\nCreate the layout and its fields before you create the **Custom Data Mappings**.\n\n1.  Go to the **POS Layouts** list.\n2.  Create a **POS Layout** for `contact` with the values below. The layout registers the columns whether or not it is displayed, so any identifier works; the example uses `resident_lookup`.\n3.  On the layout, create one **POS Layout Field** per column, with the **Field Name** in lowercase: `bill_customer_number__c`, `outstanding_balance__c`, `property_tax_balance__c`, `electric_bill_balance__c`, `water_bill_balance__c`, `parking_fines_balance__c`, `bills_json__c`.\n\n| Field | Value |\n|-------|-------|\n| **Identifier** | `resident_lookup` |\n| **Object Name** | `contact` |\n| **Type** | `list` |\n\n:::warning\nThe order matters. A **Custom Data Mapping** alone does not put a\nstandard-object custom field on a device. The device fetches an extra column only\nwhen a **POS Layout** references it, and creating the **Custom Data Mapping** is what\ntriggers the repoll that fetches it. Create the mapping first and the repoll has\nalready run without the column, leaving the field permanently blank. Editing the\nmapping does not re-trigger it, so it has to be deleted and recreated.\n:::\n\n:::warning\nLowercase matters here. A capitalized **POS Layout Field** name renders a column\nwith a correct header and a permanently empty value.\n:::\n\n### Step 2: Add the Custom Data Mappings\n\n1.  Go to the **Custom Data Mappings** list.\n2.  Create one **Custom Data Mapping** per field, with **Object API Name** `contact`, **Access Level** `read`, and the **Field API Name** in the same lowercase form as the layout field.\n\nCreating these triggers the repoll that brings the columns down.\n\n:::warning\nEnter the **Field API Name** in lowercase, even though Salesforce displays it\ncapitalized. Layout field names are lowercased for you. This one is not: it is\nstored exactly as typed, and the usual mistake is pasting the API name straight\nout of Salesforce.\n\nA mapping saved as `Bill_Customer_Number__c` does not line up with the lowercased\ncolumn the device builds, and the field is then left out of the payload\naltogether rather than arriving empty. Nothing reports it. The layout is right,\nthe mapping is right, and the field simply never appears on the register.\n\nIf a mapped field never reaches a device, check this before anything else.\n:::\n\n### Step 3: Resync the device\n\nThe columns appear on the next resync, not immediately.\n\n1.  On the register, run a full **Clear \u0026 Resync** (see [POS storage, sync, and device administration](pos-storage-sync-and-administration)). A delta sync does not pick up a field that was not in the schema it last synced against.\n2.  Confirm what arrived by reading the device's database rather than by testing template syntax. See [Verify custom data is available in Liquid](verify-custom-data-in-liquid).\n\nThe seven columns now appear on every Contact record in the device's `contact`\nstore.\n\n### Step 4: Read the data in a POS view\n\nA POS view reads the device's local database directly. The database is named\n`storeconnect` and the object store is named after the object.\n\n:::note\n`storeconnect` is the default and is what you will see on a normal register. The\nname gains a suffix, `storeconnect__\u003cstorage_key\u003e`, only when the register URL\ncarries a `storage_key` parameter, which is an opt-in for running more than one\nregister in a single browser profile. Nothing in the application adds it for you.\n:::\n\n```js\n\nfunction openDb() {\n  return new Promise(function (resolve, reject) {\n    var req = indexedDB.open('storeconnect');\n    req.onsuccess = function () { resolve(req.result); };\n    req.onerror = function () { reject(req.error); };\n  });\n}\n\n// Device keys are lowercase (see the warning below this example). Lowercase\n// the name you look up rather than building a capitalized variant.\nfunction readField(rec, base) {\n  var want = base.toLowerCase();\n  var keys = Object.keys(rec);\n  for (var i = 0; i \u003c keys.length; i++) {\n    if (keys[i].toLowerCase() === want) {\n      var v = rec[keys[i]];\n      if (v !== undefined \u0026\u0026 v !== null) return v;\n    }\n  }\n  return null;\n}\n\n// Key PRESENCE, not value. A resident who owes nothing and a column that has\n// not reached the device both read as null, and telling a clerk \"no bills due\"\n// when the truth is \"not synced yet\" is the worse of the two errors.\nfunction hasField(rec, base) {\n  var want = base.toLowerCase();\n  return Object.keys(rec).some(function (k) { return k.toLowerCase() === want; });\n}\n\nfunction findByBillNumber(query) {\n  return openDb().then(function (db) {\n    return new Promise(function (resolve, reject) {\n      var req = db.transaction('contact', 'readonly').objectStore('contact').getAll();\n      req.onsuccess = function () {\n        db.close();\n        resolve(req.result.filter(function (c) {\n          var bill = String(readField(c, 'bill_customer_number__c') || '')\n            .replace(/\\D/g, '');\n          return bill \u0026\u0026 bill === query;\n        }));\n      };\n      req.onerror = function () { db.close(); reject(req.error); };\n    });\n  });\n}\n```\n\n:::warning\nEvery key on the device is lowercase, so lowercase the name you look up. The\nserver lowercases object and field names when it builds the device schema, and the\nfield list it sends is derived from that lowercased set, so a record's properties\ncome through lowercase whatever the capitalization in Salesforce.\n`bill_customer_number__c` is the key, and there is no capitalized variant to find.\n\nNever build a candidate key by capitalizing a field name. It cannot match, and\nit fails looking exactly like missing data. Read the keys off the record, or\nlowercase the field name before comparing.\n:::\n\n:::note\nCasing does matter one layer earlier, on the **Field API Name** of the Custom Data\nMapping, which is the one value stored exactly as an administrator typed it. That\nis a different problem with a different symptom, covered in\n[Add the Custom Data Mappings](#step-2-add-the-custom-data-mappings): it stops the\nfield reaching the device at all, rather than changing the key it arrives under.\n:::\n\n:::note\nRead the whole store with `getAll()` and filter in JavaScript. Do not design\naround device-side filtering.\n:::\n\n### Step 5: Let the clerk take the payment\n\nTwo POS actions complete the counter flow. Attach the resident to the cart, then\nadd a line for the amount they are paying:\n\n```js\n\n// Attach the resident, so the payment is recorded against them.\nscAction('cart:add_contact', { contact_sc_id: contact.s_c__sc_id__c });\n\n// Add the bill as a line at the amount owed. The product is a variable-priced\n// payment product, one per revenue category, so the money lands in the right\n// place in reporting.\nscAction('cart:add_product', {\n  product_code: 'city-payment-electric',\n  quantity: 1,\n  unit_price: 58.00,\n  name: 'Electric Bill - ELEC-2026-08-BJ'\n});\n```\n\nThe clerk then takes payment at checkout like any other sale.\n\n:::warning\nDo not call `modal:close` after `cart:add_product`. Adding the product can open\nthe platform's own prompt on top of your view, and `modal:close` closes the\ntopmost modal. Closing on a timer dismisses that prompt a moment after it appears,\nwhich reads to the clerk as the window reverting on its own.\n\nSupplying `unit_price` suppresses the variable-price prompt, but only that one. A\nproduct with variants, a rental product, or a voucher product that uses assets\nopens a prompt regardless of the price you pass, so do not assume a priced line\nadds silently. Use a plain, variable-priced payment product with no variants for\nthis flow.\n:::\n\n:::note\nAdding a payment line does not mark the source bill as paid. Writing the payment\nback to `Citizen_Bill__c`, and to the billing system behind it, is integration\nwork, such as a Flow on the resulting Order, or middleware. Decide where that belongs\nbefore you go live.\n:::\n\n## Configuration summary\n\n| Setting | Where | Website | POS |\n|---------|-------|---------|-----|\n| **Custom Data Mapping** on the custom object | **Custom Data Mapping** | Required | Not sufficient on its own |\n| Sync Flows on the custom object | Two record-triggered Flows | Required | Required |\n| Projection fields on Contact | Custom fields plus a roll-up Flow | Not needed | Required |\n| **POS Layout** for `contact` | **POS Layout**, type `list` | Not needed | Required |\n| **POS Layout Field** per column | **POS Layout Field**, lowercase, created before the mapping | Not needed | Required |\n| Field-level security for the Sync user | Profile or permission set | Required | Required |\n\n## What each surface cannot do\n\n- The website cannot filter on a picklist, currency, percent, or multi-select picklist field, whatever the mapping. Text, number, date, boolean and lookup fields all filter. Where a field's type is unsupported, filter on one that is and narrow the rest in Liquid.\n- The POS cannot query a custom object at all. It reads projected columns from its own local database, and only after a resync.\n- Neither writes back to the source bill. Marking a bill paid is integration work."}