POS Apps cho phép shop tự xây dựng app (viết tay hoặc vibe-code bằng công cụ AI), upload lên Pancake POS và dùng ngay trong giao diện POS. App chỉ hoạt động trong shop đã cài, không phát hành công khai.
App có thể:
- Thêm action vào menu thao tác có sẵn, ví dụ "Đẩy sang ERP" trong menu thao tác của đơn hàng.
- Gắn vào đơn hàng, sản phẩm, phiếu nhập, phiếu xuất và phiếu chuyển kho với cùng bộ extension point và cùng cách viết code.
- Chèn block vào màn hình có sẵn, ví dụ một box "Công nợ khách hàng" trong form đơn.
- Thêm trang riêng vào menu của POS, ví dụ màn "Đối soát công nợ".
- Chạy hook trước hoặc sau một thao tác, ví dụ chặn lưu đơn khi khách vượt hạn mức nợ.
- Đọc và ghi dữ liệu của shop qua Open API, theo đúng quyền của người đang dùng.
App không sửa, ẩn hay thay thế giao diện và action có sẵn của POS.
Thuật ngữ
| Thuật ngữ | Nghĩa |
|---|---|
| App | Gói code của shop, gồm manifest và các file đã build. Mỗi lần upload tạo một version. |
| Extension | Một phần của app gắn vào một vị trí trong POS. Một app có nhiều extension. |
| Extension point / target | Vị trí POS mở sẵn cho app, ví dụ order.details.action hay purchase.index.action. Xem Extension point. |
| Action extension | Thêm mục vào menu thao tác có sẵn. |
| Block extension | Chèn một box vào màn hình có sẵn. |
| Page extension | Trang riêng của app, mở từ menu POS. |
| Hook | Logic chạy trước/sau một thao tác có sẵn, không có giao diện. |
| should_render | Điều kiện quyết định action hoặc block có hiện hay không. |
| Worker UI | Cách hiển thị của block và modal: code app chạy cô lập trong Web Worker, chỉ mô tả giao diện bằng element pos-*, POS vẽ bằng component thật. |
| App token | Token ngắn hạn POS cấp cho app để gọi Open API thay cho người đang dùng. |
Kiến trúc
Trang POS (pos.pages.fm) Sandbox của app (domain riêng)
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ Vị trí extension │ postMessage│ Iframe ẩn │
│ ├─ Block / modal (Worker UI) │◄──────────►│ └─ Web Worker │
│ │ POS vẽ bằng component chuẩn │ │ ├─ Code của app + Preact │
│ ├─ Trang của app (iframe) │ │ └─ Runtime POS (pos, DOM) │
│ └─ Hook, điều kiện hiển thị │ │ Iframe trang: SPA của app │
│ Cầu nối: dữ liệu ngữ cảnh, │ │ └─ SDK client (connect) │
│ token, toast, điều hướng │ │ fetch ──► Open API (app token) │
└──────────────────────────────────┘ └──────────────────────────────────┘Nguyên tắc:
- Code app không bao giờ chạy trên domain của POS. App chạy trong web worker hoặc iframe trên một domain sandbox riêng cho từng app, nên không đọc được phiên đăng nhập, cookie hay giao diện của POS.
- POS vẽ giao diện, app chỉ mô tả. Với block và modal, app dựng cây element
pos-*. POS nhận mô tả đó và vẽ bằng component của chính POS, nên giao diện giống hệt POS và app không chạm được vào trang. - Mọi dữ liệu đi qua Open API, bằng app token có quyền của người đang dùng.
- Vị trí, component và dữ liệu ngữ cảnh là hợp đồng có version (
api_versiontrong manifest). POS giữ tương thích ngược trong thời gian hỗ trợ của mỗi version.
SDK
App không tự viết phần giao tiếp với POS. Phần này chia làm hai:
- Runtime POS: POS nạp sẵn vào worker trước code của app. Gồm biến toàn cục
pos, DOM tối giản cho Worker UI, elementpos-*, kênh postMessage, app token và client gọi Open API. Runtime không nằm trong bundle của app, nên POS cập nhật hoặc vá lỗi mà app không phải deploy lại. - Gói SDK cài vào dự án của app:
| Gói | Vai trò |
|---|---|
@pancake-pos/ui-extensions |
Type cho pos và element pos-*, helper defineExtension; client connect() cho trang iframe |
@pancake-pos/ui |
Theme và component theo style POS cho trang iframe |
@pancake-pos/app-cli |
Lệnh pos-app: đăng nhập, chạy thử với POS, kiểm tra code, build, upload |
@pancake-pos/eslint-plugin |
Luật kiểm tra code; editor báo lỗi ngay khi gõ, pos-app check dùng cùng bộ luật |
create-pos-app |
Tạo dự án mẫu kèm AGENTS.md cho công cụ AI |
Phiên bản gói đi theo api_version (gói 2026.1.x cho 2026-01). Lệnh pos-app check kiểm tra manifest và chặn các lỗi hay gặp khi AI sinh code (dùng DOM thật, thẻ HTML thường, method hoặc domain chưa khai). Chi tiết: SDK & CLI.
Chọn kiểu hiển thị
| Nhu cầu | Kiểu | Lý do |
|---|---|---|
| Box hoặc modal nằm trong màn hình có sẵn | Worker UI | Giao diện giống POS, nhẹ, popover/modal hoạt động bình thường |
| Thao tác một bước, không cần giao diện | Headless (chỉ worker) | Chạy xong báo toast, không mở gì thêm |
| Điều kiện hiện nút, hook trước/sau khi lưu | Headless | Không có giao diện |
| Màn hình riêng phức tạp: chart, bản đồ, kéo thả, thư viện UI bên ngoài | Iframe | Tự do dùng mọi thư viện web |
Quy tắc ngắn: nằm trong màn hình POS → Worker UI; là màn hình riêng → iframe; không có giao diện → headless.
Worker UI hoạt động thế nào
Web worker không có DOM. Runtime POS nạp vào worker một DOM tối giản, chỉ đủ để Preact hoặc JS thuần dựng cây element pos-*. Mỗi khi cây thay đổi, runtime ghi lại thay đổi (thêm, xóa node, đổi thuộc tính, đổi chữ) và gửi về POS thành một lô sau mỗi lượt render. POS kiểm tra từng element rồi vẽ bằng component thật. Khi người dùng bấm hoặc gõ, POS gửi sự kiện vào worker và code của app nhận giá trị qua event.detail.
Worker (code của app) POS
setState → Preact render lại trên DOM tối giản
→ runtime gom thay đổi thành một lô ────► kiểm tra → vẽ bằng component POS
onChange(event.detail) ◄───────────────────────── người dùng bấm, gõHệ quả:
- State nằm trong code của app (
useState, biến…). POS chỉ giữ bản vẽ giao diện, không giữ state của app. - Mỗi lần extension hiển thị chạy trong một worker riêng. Đóng modal hoặc rời màn hình thì worker dừng và state mất; dữ liệu cần giữ lâu thì lưu qua Open API hoặc server của app.
- Ô nhập không giật khi app đang bận: POS hiện ngay chữ người dùng gõ rồi mới báo cho app.
- Runtime có sẵn trong worker; app không cài thêm thư viện nào để kết nối với POS.
- Chỉ dùng được element trong danh mục component. Element lạ sẽ không hiển thị và extension báo lỗi.
- Không có
window,document.querySelector,localStorage,canvashay đo kích thước DOM. Thư viện chart hoặc UI bên ngoài không chạy trong worker; khi cần, dùng component do POS cung cấp hoặc chuyển sang trang iframe.
Mô hình bảo mật
| Rủi ro | Cách xử lý |
|---|---|
| App dùng phiên đăng nhập của POS | Code app chạy trên domain sandbox riêng, không đọc được cookie hay token của POS |
| App đọc hoặc sửa giao diện POS | Worker không có DOM thật; POS chỉ vẽ element pos-* hợp lệ; iframe khác domain |
| Dữ liệu do khách nhập chứa mã độc | Worker UI không nhận HTML thô; với iframe, thiệt hại giới hạn trong quyền của app token |
| Nhân viên dùng app để vượt quyền | App token luôn mang quyền của người đang dùng; chỉ người có quyền quản lý cấu hình mới upload app |
| App gửi dữ liệu ra ngoài | App chỉ gọi được, và chỉ hiện ảnh từ, domain khai trong network.allowed_origins; admin thấy danh sách khi kích hoạt |
| App làm chậm hoặc treo POS | Tải khi cần, timeout, dừng worker treo, giới hạn kích thước, nút tắt app khẩn cấp |
| Token bị lộ | Token hết hạn sau 15 phút, chỉ dùng cho một shop, một user, một app; thu hồi ngay khi tắt app |
Truy cập dữ liệu
App dùng chính Open API hiện có: cùng endpoint, format, phân trang. Khác ở credential:
- Không có API key cố định trong code. POS tự cấp app token khi app chạy.
- Quyền của app = quyền của người đang dùng ∩ giới hạn admin đặt cho app (kho, trường bị ẩn, chỉ đọc…).
- Mọi thao tác ghi được audit theo người dùng thật và app.
Chi tiết: Truy cập dữ liệu.
Vòng đời app
- Người có quyền quản lý cấu hình của shop upload bundle bằng CLI. Mỗi lần upload tạo một version.
- POS kiểm tra manifest: target hợp lệ, file tồn tại, kích thước trong giới hạn.
- Admin xem quyền dữ liệu và domain bên ngoài mà app yêu cầu, xác nhận cửa hàng chịu trách nhiệm về app, rồi kích hoạt version trên trang Ứng dụng tuỳ biến.
- Mặc định mọi thành viên của shop đều thấy app; giới hạn theo bộ phận chưa có trên trang quản lý.
- Có thể rollback về version trước, tắt app bất kỳ lúc nào. Tắt app thu hồi toàn bộ app token đang dùng.
Giới hạn
| Hạng mục | Giới hạn |
|---|---|
| Gói (bundle) mỗi version | ≤ 10 MB, tối đa 1.000 file |
| Module extension (Worker UI, headless) | ≤ 256 KB gzip mỗi module |
| Số extension mỗi app | 30 |
Domain trong network.allowed_origins |
10 |
| Số app đang bật mỗi shop | 10 |
| Số version giữ lại mỗi app | 20 bản gần nhất, cộng bản đang chạy |
| Tổng dung lượng app mỗi shop | 500 MB |
| Số element Worker UI mỗi extension | 2.000 |
| Cập nhật giao diện Worker UI | 60 lần mỗi giây; vượt liên tục 3 giây thì extension bị dừng |
Timeout should_render |
300 ms |
Timeout hook before_submit |
mặc định 2 giây (timeout_ms), tối đa 5 giây |
Thời gian chạy hook after_save, action headless |
30 giây |
| Tần suất gọi Open API | Dưới 30 lượt đọc (GET) và dưới 100 lượt ghi mỗi giây, tính theo app + người dùng + shop + endpoint |
| Hạn app token | 15 phút, không quá phiên đăng nhập của người dùng (SDK tự gia hạn) |
| Nhật ký lỗi mỗi app | 500 lỗi gần nhất, giữ 7 ngày |
Phạm vi hỗ trợ
- Chỉ áp dụng trên POS web. App mobile chưa chạy extension.
- Hook phía giao diện chỉ chạy khi thao tác trên POS web, ở đúng vị trí đã gắn hook. Bản ghi tạo hoặc sửa từ Open API, app mobile, cập nhật hàng loạt hay đồng bộ sàn không đi qua hook. Quy tắc bắt buộc đúng mọi lúc cần làm ở phía server.
- App không chạy nền. Tác vụ định kỳ hoặc xử lý webhook cần backend riêng của shop (xem Truy cập dữ liệu).
Lộ trình
| Giai đoạn | Nội dung |
|---|---|
| PoC | Sandbox, app token, block chi tiết đơn, action trong menu thao tác đơn, trang iframe, app mẫu viết bằng AI |
| Giai đoạn 1 | Upload, version, rollback, phân quyền hiển thị, audit, giới hạn tần suất, danh mục component v1, SDK, CLI |
| Giai đoạn 2 | Hook before_submit, trang Worker UI (render: "worker"), mẫu in tuỳ biến, cột tuỳ biến, block khách hàng, webhook và service key theo app |
| Giai đoạn 3 | Cân nhắc app mobile, cài một app cho nhiều shop cùng chủ |
