shop-tools — V1 設計書(セルフスキャン+店員確認+Square Terminal 決済)


0. 背景と目的

店舗の物販で、カート操作を顧客のスマホ(PWA)に分離し、店員が確認したうえで Square Terminal で決済する。決済そのものは店舗の既存 Square アカウントに委ね、当方はソフトウェアベンダーに徹する。

なぜ Square Terminal か

  • 既存の物販で Square アカウント/ロケーション/カタログ/在庫を運用済み
  • Terminal API は 明細付きチェックアウト(order_id + show_itemized_cart)に対応
  • 日本では PayPay(QR)/iD・QUICPay・交通系(FeliCa) に対応
  • Stripe Terminal は日本ではプレビュー扱いが残り、明細カート表示がなく、iD/QUICPay/PayPay 非対応

スコープ(V1)

含む 含まない(非目標)
顧客PWAのカート/合計/6桁コード 現金決済(V1は対象外、V1.1で追加)
店員PWAの注文確認/決済開始 返金UI(Square Dashboard で実施)
Square Terminal 決済(実機)/モック(デモ) 在庫管理・発注(Square 側)
店舗カタログ参照(Square Catalog) 重量・量り売り
店舗別・レジ別の分離 オフライン決済
2レジ同時運用 会計・税務連携
本番移行を見据えた環境分離 非換金ポイント(別フェーズ)

対象店舗の条件

  • SKU 数 〜100 程度(コード上の上限は設けない)
  • 重量・量り売りなし
  • 1店舗あたり 1〜数レジ、1レジ 1 Terminal
  • 通貨は JPY のみ

現金の扱い

  • V1 はカード/NFC/PayPay/電子マネー(Terminal)のみ
  • 現金は V1.1 で追加可能。Terminal では扱えず、Payments API で記録する(§10.1)
  • デモ(Terminal モック)では現金を扱わない

1. 決定事項

項目 決定 理由
カート 顧客PWA(公開 CloudFront) 客のスマホが店舗LAN外でも使える
店員 店員PWA(公開 CloudFront、自レジのみ) 運用が単純、権限分離
順序制御 店員が6桁コードを入力(案2) 人が直列化するため競合に強い
レジ識別 店員側のみ(キットのランチャー ?r=reg-01) 顧客はレジを知らなくてよい
注文のレジ割当 lookup 時に claim 同時入力は片方だけ成功
商品マスタ Square が正、BE は参照+キャッシュ 二重管理を避ける
税・合計 本番は Square total_money を信頼 端末請求額と一致させる
認証(本番) 店員は Cognito 監査・退職者対応
認証(デモ) 店舗PIN → JWT 配布キットを軽くする
秘密情報 SSM Parameter Store(SecureString) 無料。OAuth 移行時に Secrets Manager
BE API Gateway + Lambda + DynamoDB 常時稼働サーバー不要
端末(デモ) Terminal モック(店舗PCの Node) 実機なしで説明できる
配布単位 1キット = 1店舗・1レジ・1端末 同一 shopId で複数キット可
OS Windows / Mac(Node 18/20 LTS) 先方が導入
通信 オンライン必須 Square も元々オンライン前提

2. 全体アーキテクチャ

                    ┌──────────────────────────────┐
                    │ CloudFront(1ディストリビューション) │
                    │  /s/{shopId}/customer → S3      │
                    │  /s/{shopId}/staff    → S3      │
                    │  /s/{shopId}/setup    → S3      │
                    │  /api/*        → API Gateway    │
                    │  /webhooks/*   → API Gateway    │
                    └──────────────┬───────────────┘
                                   │
                    ┌──────────────▼───────────────┐
                    │ Lambda(api / webhook)        │
                    │  ├ core(状態機械・IF)         │
                    │  ├ adapters(mock / square)   │
                    │  └ DynamoDB / SSM / Cognito    │
                    └──────────────┬───────────────┘
                                   │
                    ┌──────────────▼───────────────┐
                    │ Square(Catalog/Orders/Terminal)│
                    │ 実機 or Sandbox シミュレート端末  │
                    └──────────────────────────────┘

[店舗PC(キット / Node)] /terminal → Terminal モック(ローカル配信)
  • PWA と API を同一オリジンに置き CORS を不要にする
  • 端末モックは店舗PCで動かし、クラウドBEとポーリングで同期
  • 店舗PCが落ちても注文・決済記録はクラウドに残る

3. テナントモデル(今は1店舗、増やせる設計)

Merchant(Square アカウント)
 └ Shop(Square Location)        ← 今は1つ
    └ Register(Terminal)        ← 今は1つ(将来 複数)
       └ Staff(Cognito ユーザー)
  • すべてのデータに merchantId / shopId / registerId を持たせる
  • 店舗追加は 同じ Square アカウントに Location を追加し、shopId を発行
  • Square アカウントが別になる場合のみ OAuth(店舗ごとのトークン)へ切替
  • URL は /s/{shopId}/...。将来は店舗ドメイン(CloudFront 別名 + ACM us-east-1)
  • 静的ファイルは1コピー。CloudFront Functions で /s/{shopId}/* を /* に書き換え、shopId はパスまたはヘッダーで PWA に渡す
  • 完全隔離が必要になったら店舗ごとにスタック分割も可能(データモデルは同一)

4. 業務フロー

正常系

1. 顧客: 商品タイル/検索 → カート → 合計確認 → 注文作成
        → 6桁コード(+QR)を表示
2. 店員: 6桁を入力 → BEが注文を返す(同時にそのレジへ claim)
3. 店員: 明細・数量・合計を確認 → 「決済」
4. BE: レジのロック取得 → Square Order 作成 → Terminal checkout
5. Terminal: 明細+合計を表示 → 客がカード/PayPay/電子マネーで決済
6. Square → BE: webhook(terminal.checkout.updated / payment.updated)
7. BE: Payment を取得し金額一致を検証 → 注文 PAID
8. 顧客PWA: ポーリングで完了を検知 → 完了画面/レシート案内
9. Square Dashboard: 売上・在庫・レシートに反映

例外的な流れ

事象 挙動
客がコードを出さず離脱 コード TTL 10分で失効。注文は EXPIRED
別レジが同じコードを入力 claim 済みのため失敗(「他レジで処理中」)
端末が使用中 QUEUED にし、解放後に FIFO で送出
端末オフライン checkout が失敗/SENT のまま。店員に再試行を促す
カード拒否 FAILED を CLAIMED に戻し、同一注文で再試行
客が端末でキャンセル CLAIMED に戻す(再試行可)。明示取消のみ CANCELED
webhook 未達 GET /orders/{id} で Square を再取得(フォールバック)
金額不一致 PAID にせず FAILED+要調査フラグ

5. 状態遷移

                lookup(claim)
   OPEN ─────────────────────▶ CLAIMED
     │                            │ checkout(ロック取得)
     │                            ▼
     │                          QUEUED ──(ロック解放/順番)──▶ SENT
     │                                                      │
     │                                          webhook    ▼
     │                                              IN_PROGRESS
     │                                                      │
     │                                       ┌──────────────┼──────────────┐
     │                                       ▼              ▼              ▼
     │                                     PAID          FAILED        CANCELED
     │                                       │              │
     │                                       │        (再試行可)→ CLAIMED
     └──────────── TTL ────────────▶ EXPIRED
状態 意味 遷移条件
OPEN 注文作成済み、レジ未割当 POST /orders
CLAIMED 店員が確認し、レジに割当 lookup の条件付き更新成功
QUEUED レジのロック待ち checkout 時にロック取得失敗
SENT Square checkout 作成済み createCheckout 成功
IN_PROGRESS 端末が処理中 webhook IN_PROGRESS
PAID 決済完了・金額一致 checkout COMPLETED + Payment 検証OK
FAILED 拒否・金額不一致 webhook FAILED/検証NG
CANCELED 明示取消 店員の取消
EXPIRED 期限切れ TTL/deadline
  • claim は条件付き更新(registerId が null のときのみ設定)
  • ロックはレジ単位(REG#{registerId}.currentOrderId が null のときのみ設定)
  • 状態は前進のみ。遅延 webhook で逆行しないよう、現在状態との比較で無視する
  • CLAIMED / QUEUED / SENT も、注文 TTL(30分)や checkout の deadline で期限切れになる
  • 現金(V1.1) は CLAIMED から直接 PAID(Terminal を経由しない)

6. データモデル(DynamoDB 1テーブル)

PK SK 主な属性
MERCHANT#{merchantId} META squareAccountRef, tokenParamName, createdAt
SHOP#{shopId} META merchantId, locationId, name, currency, timezone
SHOP#{shopId} REG#{registerId} terminalId, currentOrderId, lockExpiresAt, status
SHOP#{shopId} ORDER#{orderId} registerId(null可), status, total, tax, paymentMethod(CARD_TERMINAL/CASH), cashReceived, change, squareOrderId, squareCheckoutId, paymentIds, idempotencyKey, attempt, correlationId, createdAt, paidAt
ORDER#{orderId} ITEM#{n} catalogObjectId, variationId, name, unitPrice, quantity, taxAmount
SHOP#{shopId} CODE#{code} orderId, claimedBy, TTL
STAFF#{sub} META shopId, registerId, role, active
WEBHOOK#{eventId} META type, receivedAt, TTL(30d)
CATALOG#{catalogObjectId} META name, category, taxIds, updatedAt
CATALOG#{catalogObjectId} VAR#{variationId} sku, barcode, price, currency

GSI

名前 キー 用途
GSI1 status + createdAt キュー取出し、滞留監視
GSI2 barcode スキャン(M8)・検索補助

方針

  • orderId は ULID 等のグローバル一意ID(ORDER#{orderId} を PK にするため)
  • barcode は GSI2 用に Variation 側へトップレベル属性として複製する
  • 時刻は UTC(ISO8601)で保存、表示は JST
  • TTL:CODE# 10分、WEBHOOK# 30日、ORDER# は 90日でアーカイブ(本番)
  • 本番は PITR(ポイントインタイムリカバリ) を有効化
  • 個人情報は最小限(顧客は匿名、スタッフは Cognito の sub のみ)

7. API 設計

共通:Content-Type: application/json、X-Correlation-Id を任意で受付・生成。 エラーは { "error": { "code", "message", "details?" } } に統一。

7.1 公開(顧客)

GET /api/config?shop={shopId}

{
  "shopId": "shop-01",
  "shopName": "デモ商店",
  "currency": "JPY",
  "taxIncluded": true,
  "features": { "scan": false, "search": true },
  "demo": true
}

GET /api/catalog?q=&category=&cursor=&limit=50

{
  "items": [
    {
      "catalogObjectId": "ITEM_ID",
      "variationId": "VAR_ID",
      "name": "コーヒー豆 200g",
      "price": 1200,
      "currency": "JPY",
      "category": "食品",
      "barcode": "4901234567894"
    }
  ],
  "nextCursor": null,
  "version": "catalog-2026-10-29T00:00:00Z"
}
  • limit は最大 100。最初からページングを実装(SKU 増加に備える)
  • カタログは BE がキャッシュ。更新は catalog.version.updated と定期同期

POST /api/orders

{
  "shopId": "shop-01",
  "items": [{ "catalogObjectId": "ITEM_ID", "variationId": "VAR_ID", "quantity": 2 }],
  "clientToken": "b0c1..."
}
{
  "orderId": "ord_...",
  "code": "482913",
  "total": 2400,
  "currency": "JPY",
  "expiresAt": "2026-10-29T12:40:00Z",
  "orderToken": "eyJ..."
}
  • 単価はサーバーがカタログから取得。クライアントの金額は無視
  • clientToken で冪等化(同じトークンは同じ注文を返す)
  • 6桁コードはランダム生成、SHOP#{shopId} スコープで一意

GET /api/orders/{orderId}(Authorization: Bearer {orderToken})

{
  "orderId": "ord_...",
  "status": "PAID",
  "total": 2400,
  "code": "482913",
  "paidAt": "2026-10-29T12:35:10Z"
}

7.2 店員

POST /api/staff/login

  • 本番:Cognito の ID トークンを Authorization に付与(この API は使わない)
  • デモ:{ "shopId", "registerId", "pin" } → { token, expiresAt }

POST /api/staff/lookup(要 JWT)

{ "code": "482913" }
{
  "order": {
    "orderId": "ord_...",
    "status": "CLAIMED",
    "items": [{ "name": "コーヒー豆 200g", "quantity": 2, "unitPrice": 1200 }],
    "total": 2400,
    "currency": "JPY"
  },
  "claimed": true
}
  • 同時入力は条件付き更新で片方のみ成功
  • すでに他レジが claim 済みなら 409 ALREADY_CLAIMED

POST /api/orders/{orderId}/checkout(要 JWT)

{}
{ "status": "SENT", "terminalId": "term-01" }
  • registerId / terminalId はサーバー側で解決する(デモはJWT、本番は STAFF#{sub})。クライアントから端末を指定させない
  • レジのロックは条件付き更新で取得し、取得できなければ QUEUED
  • Square Order は初回のみ作成(冪等キー = orderId)。checkout は試行ごとに作成(冪等キー = orderId-attempt)

エラー:409 TERMINAL_BUSY / 409 INVALID_STATE / 422 SQUARE_ORDER_FAILED

POST /api/orders/{orderId}/cancel(要 JWT)

{ "reason": "customer_left" } → { "status": "CANCELED" }

POST /api/orders/{orderId}/cash(要 JWT、V1.1)

{ "buyerSuppliedMoney": 10000 }
{ "status": "PAID", "change": 7600, "paymentId": "..." }
  • source_id: "CASH" で CreatePayment。order_id を紐付ける
  • 預かり金 < 合計なら 422 INSUFFICIENT_CASH
  • 端末ロックは不要(Terminal を使わない)

7.3 Webhook

POST /webhooks/square

  • Square-Signature を生ボディで検証
  • event_id で冪等化し、即 200 を返す(重い処理は非同期)
  • 購読:terminal.checkout.created / terminal.checkout.updated / payment.updated / catalog.version.updated / device.code.paired

7.4 モック専用(ADAPTER=mock のときのみ有効)

メソッド パス 用途
GET /api/mock/checkouts?registerId= 端末が受信
POST /api/mock/checkouts/{id}/complete タッチ成功
POST /api/mock/checkouts/{id}/cancel 客がキャンセル
POST /api/mock/checkouts/{id}/timeout 期限切れ
POST /api/demo/reset デモ初期化
  • ADAPTER !== 'mock' では 404(本番に露出しない)
  • 成功・失敗は 本番と同じ共通ハンドラ handleCheckoutEvent() を通す

8. 認証・認可

対象 デモ 本番
顧客 匿名。orderToken(HMAC、注文単位) 同左+レート制限
店員 店舗PIN → BE署名JWT Cognito(管理者作成、セルフサインアップ不可)
管理 なし IAM / Cognito / Square Dashboard
  • 本番の認可は JWT の sub → STAFF#{sub} を引いて shopId / registerId / role を決定
    • JWT にレジ情報を詰めない(異動・兼務に強い、トークン肥大を避ける)
  • 端末(terminalId)はサーバー側で解決。クライアントの指定を信用しない
  • 顧客 orderToken は orderId と有効期限に対する HMAC。他人の注文を参照できない
  • レート制限:lookup 5回/分/ユーザー、orders 10回/分/IP(API Gateway スロットリング+アプリ側)

9. 差し替えインターフェース(本番移行の肝)

IF mock production
CatalogProvider シードJSON Square Catalog API
PricingProvider ローカル税計算 Square order.total_money
TerminalAdapter モックcheckout Terminal API
PaymentVerifier 即時成功 Payment 取得+金額照合
SecretProvider 環境変数 SSM(将来 Secrets Manager)
AuthProvider 店舗PIN Cognito
Notification ポーリング ポーリング(将来 Push)
  • TerminalAdapter.createCheckout() / cancelCheckout() を共通化
  • 完了は外部イベントとして handleCheckoutEvent() に集約
    • 本番:Square webhook
    • デモ:モック端末の操作 → 同じ関数
  • 契約テストを mock / sandbox の両方で実行し、差し替え時の挙動差を検出

10. 金額・税の扱い

  • 通貨は JPY(ゼロ小数)。amount は「円」そのまま(×100 しない)
  • 顧客PWAの合計は概算。確定額は Square Order の total_money
  • フロー:
    1. POST /orders 時にカタログ価格で概算を返す(表示用)
    2. checkout 時に Square Order を作成し total_money を取得
    3. checkout の amount_money は total_money と一致させる(autocomplete=true の前提)
    4. 概算と確定額が異なる場合はログ+店員画面に差分表示(税設定のズレ検出)
  • 端数処理は Square の設定に従う(BE で独自丸めをしない)

10.1 現金対応(V1.1)

  • Terminal は現金非対応。Payments API CreatePayment(source_id: "CASH")で記録する
  • フロー:
    1. 店員が注文を claim → 明細確認
    2. 「現金」→ 預かり金を入力
    3. BE: Square Order を作成(未作成なら)→ CreatePayment
      • order_id で注文明細と紐付け(在庫・レポートに反映)
      • cash_details.buyer_supplied_money に預かり金
      • idempotency_key = orderId-cash-attempt
    4. Square が change_back_money を計算 → 注文 PAID
    5. 顧客PWAに完了通知(ポーリング)
  • 現金は Terminal のロックを使わない(カード処理と独立)
  • ドロワー:API で物理的に開ける手段はない。Square Terminal のドロワー対応は Square POS アプリ(スタンドアロン) の機能で、Terminal API モードでは自動開放されない。手動ボタン付きドロワー、または現金用に別端末(Square POS)を併用する
  • レシート:Square が receipt_url を返す。ペアリング中は外部プリンタ不可のため、画面表示・メール送信・別プリンタで対応
  • 返金:Refunds API または Square Dashboard で記録(現金の受け渡しは店舗)
  • 現金管理(釣銭準備金・入出金・シフト):Cash Drawer Shifts API は記録用。物理開放は不可。フル機能が必要なら Square POS/Retail

11. カタログ同期

  • 初回・TTL切れで 全件同期(SearchCatalogObjects、ページング)
  • catalog.version.updated で増分同期
  • 保存:商品(Item)+バリエーション(Variation)。価格・税・バーコード・カテゴリ
  • バーコードは Variation の barcode / sku を索引化(M8 のスキャンで使用)
  • 同期失敗時は前回キャッシュを保持し、アラートを出す

12. エラー・競合・冪等

リスク 対策
二重押下 状態の条件付き更新+idempotency_key = orderId-attempt
現金の二重記録 idempotency_key = orderId-cash-attempt+状態の条件付き更新
同一コードの同時入力 registerId is null の条件付き更新
同一レジの同時会計 currentOrderId is null の条件付き更新
webhook 重複 WEBHOOK#{eventId} の条件付き Put
webhook 順不同 状態は前進のみ(現在状態と比較して古い更新は無視)
webhook 欠落 GET /orders/{id} → Square から再取得
端末オフライン checkout が進行しない/失敗。端末の電源・ネットワークを確認し、再試行導線
放置 checkout deadline_duration=PT2M+取消でロック解放
金額不一致 PAID にせず FAILED+アラート
checkout の30日削除 paymentIds を永続保存

13. 非機能

  • 時刻:DB は UTC、画面・レポートは JST
  • 可用性:Lambda + DynamoDB(オンデマンド)。単一障害点は CloudFront/Square のみ
  • 性能:1会計あたり API 数回。ポーリングは顧客10秒・Terminal 2秒(店員の決済中のみ1秒)
  • 監視:構造化ログ(correlationId)、CloudWatch アラーム(webhook失敗、SENT滞留、金額不一致)
  • 非同期:webhook の後処理(キュー送出・通知)は SQS + DLQ
  • セキュリティ:署名検証、最小権限 IAM、秘密は SSM、HTTPS のみ、WAF は任意
  • バックアップ:本番 PITR、DynamoDB エクスポート(日次)
  • コスト:デモは固定費ゼロ(SSM 無料、無料枠内)。本番小規模は月数百円+Square 手数料

14. 拡張余地(SKU 100 を目標、上限は設けない)

領域 MVP 拡張時 変更点
カタログAPI 一覧+検索+カテゴリ 数百〜数千 カーソルページングを最初から実装済み
クライアント 全件キャッシュ 増加時 遅延読込へ(UI は同一)
検索 サーバー側の単純検索 高度化 検索基盤を差し替え(IF 同一)
スキャン なし M8 で判断 FEATURE_SCAN
重量商品 対象外 将来 PricingProvider 拡張
店舗数 1 複数 shopId 分離済み。別アカウントは OAuth
決済 mock / Square 変更なし TerminalAdapter 差し替え

拡張トリガー

条件 対応
SKU > 100 スキャン有効化、検索順位改善
SKU > 500 検索インデックス導入、顧客アプリのネイティブ化検討
複数店舗 shopId 追加、必要なら OAuth
重量・量り売り スケール連携/価格埋込バーコード
返金・現金 Square Dashboard/別フロー

15. 責任分界・規制線

  • 資金は各店舗の Square 口座に直接入金される。当方は資金を預からない
  • 当方はソフトウェアベンダーであり、収納代行・資金移動・決済代行を行わない
  • 当方の単一アカウントで複数店舗の売上を集約する設計は採用しない(資金移動・収納代行の論点を回避)
  • SaaS 展開時は 店舗ごとに OAuth でトークンを分離し、資金の流れを店舗に閉じる
  • ポイント(非換金)は本設計のスコープ外。導入時は別途、前払式・資金移動に触れないことを確認する

16. 実装構成(リポジトリ)

repo/
├── apps/customer/       顧客PWA
├── apps/staff/          店員PWA
├── server/              Lambda handlers(api / webhook)
├── packages/core/       状態機械・型・インターフェース
├── packages/adapters/   mock / square
├── infra/               SAM(環境別パラメータ)
├── kit/                 店舗キット(Win/Mac)
└── tests/contract/      mock / sandbox 共通
  • 状態機械と IF を core に集約し、UI とアダプタは薄く保つ
  • 設定は config/{env}.json + SSM。環境差分をコードに埋めない
  • 契約テストを CI で回し、mock と sandbox の挙動差を検出する

17. マイルストーン

M 内容 完了条件
M0 基盤(SAM、DynamoDB、SSM、health、config) 疎通確認
M1 core(状態機械・claim・ロック)+unit 同時 claim で片方のみ成功
M2 Catalog(seed/Square)+注文API 注文作成〜コード発行
M3 Adapter(mock/Square)+checkout+webhook mock で完了まで通る
M4 Cognito+staff API 本番相当の認証で lookup/checkout
M5 顧客PWA/店員PWA(タイル+検索) 実機スマホで E2E
M6 Terminal モック+キット(Win/Mac) 両OSで起動・自動オープン
M7 契約テスト→sandbox→本番パイロット 実機で少額決済・返金確認
M8 スキャン実機検証(iOS/Android) 採用可否の判断

18. テスト方針

種別 内容
Unit 金額再計算、税、claim 競合、ロック、状態遷移、冪等
Contract mock / sandbox で同一スイート
Integration webhook 署名、順不同、重複、欠落
E2E 2レジ同時、離脱、通信断、拒否、タイムアウト
Security 認可(他店舗・他レジ)、orderToken、レート制限
本番 実機スモーク(ペアリング、明細表示、レシート、Dashboard)

19. リスク

リスク 影響 対策
既存 Terminal の再ペアリングで Square POS が使えなくなる 運用変更 事前に店舗と合意。必要なら端末を追加
Sandbox は実機ペアリング不可 検証不足 本番パイロットで実機検証
商品バーコード未登録 スキャン不可 M8 までスキャン非依存。タイル+検索で運用
税設定のズレ 金額不一致 Square total_money を正とし、差分を検知
webhook 遅延・欠落 状態不整合 ポーリングフォールバック、前進のみ更新
コードの総当たり・放置 不正・滞留 TTL、レート制限、claim、期限切れ
スタッフの取り違え 誤会計 確認画面で明細・合計を必ず表示
iOS PWA のカメラ制約 スキャン不可 M8 で実機検証。NG ならネイティブ化
現金のドロワーが自動で開かない 運用負荷 手動ドロワー/Square POS 併用。API では物理開放不可

20. 未確定・前提

  1. レシート運用(端末印刷 / デジタル / 領収書)は店舗と確定が必要
  2. 返金は Square Dashboard 運用で合意済みか
  3. 既存 Terminal を Square POS から切り替えてよいか(運用最終確認)
  4. 本番の独自ドメイン利用有無(ACM は us-east-1)
  5. スタッフの Cognito ログイン方式(メール+パスワード / MFA 有無)
  6. 営業時間・日締め・シフト連携は対象外でよいか
  7. 現金を V1.1 で実装するか、Square POS 併用(ドロワー運用)にするか

21. 参照