| owner | commerce-team |
|---|---|
| reviewers | payments-team, inventory-team |
Note
この設計書では、カート画面の「注文を確定」ボタンから注文完了までの流れを説明する。配送と請求は別の設計書で説明する。
顧客がカート画面で「注文を確定」を押す。
POST /orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 6f1c2a9e-0b7d-4f3a-9c55-1d2e8a7b4c10
{
"cartId": "c_8241",
"shipping": { "addressId": "a_17", "timeSlot": "am" },
"payment": { "methodId": "pm_card_visa" },
"items": [{ "sku": "SKU-1042", "quantity": 2 }]
}
| ステータス | 条件 |
|---|---|
| 201 | 注文確定 |
| 402 | 決済拒否 |
| 409 | 在庫切れの商品がある |
カートのすべての商品を 1 つのトランザクションで引き当てる。1 つでも足りなければ何も引き当てず、409 Conflict を返す。
UPDATE stock
SET reserved = reserved + $2
WHERE sku = $1 AND quantity - reserved >= $2;
注文が確定しなければ、引き当ては 15 分で失効する[1]。
注文金額をオーソリする。売上の確定は出荷時に行う。
Important
カード番号は注文 API に送らない。Web アプリが決済ゲートウェイに直接送り、返された payment.methodId だけを渡す。
注文と一緒に保存し、出荷時の売上確定に使う。
注文をステータス confirmed で保存し、引き当てを注文に紐付ける。
export async function confirmOrder(tx: Tx, order: NewOrder, auth: Authorization): Promise<Order> {
const saved = await tx.orders.insert({ ...order, status: 'confirmed', authCode: auth.code });
await tx.reservations.attach(order.reservationId, saved.id);
return saved;
}
注文 ID とお届け予定日を返す。
注文番号を表示し、注文確認メールを送る。
ゲートウェイが拒否コードを返す。リトライするコードは決済リトライのフローを参照。
他の顧客が買えるよう、引き当てをすぐに解放する。
Warning
拒否コードは顧客に見せない。「お支払いを完了できませんでした。」と表示し、別の支払い方法を選べるようにする。
定期購入の更新など、バッチ処理が始めたオーソリをゲートウェイが拒否したときに始まる。
| 拒否コード | リトライ |
|---|---|
processing_error |
する |
try_again_later |
する |
insufficient_funds |
しない |
card_declined |
しない |
1 分、5 分、30 分と待つ。
最初と同じ冪等キーを使う。
再試行したオーソリの結果を確かめる。
注文のステータスを paid にする。
在庫を解放し、注文を取り消す。
注文は出荷に進む。
支払い方法の更新をお願いするメールを送る。
注文を保存し、支払いを待っている。
オーソリが承認された。
配送業者に荷物を渡した。この時点で売上を確定する。
配送業者から配達完了の連絡があった。
出荷前に注文がキャンセルされた。オーソリは取り消す。
注文 API のエラー。エラーのレスポンスの本文は { "code": "ORD-011", "message": "..." }。
| コード | HTTP ステータス | 意味 | クライアントの対応 |
|---|---|---|---|
ORD-001 |
400 | リクエストの本文が JSON として正しくない。 | リクエストを直す。 |
ORD-002 |
400 | 必須の項目がない。 | リクエストを直す。 |
ORD-003 |
400 | Idempotency-Key ヘッダがない。 |
ヘッダを付ける。 |
ORD-004 |
400 | quantity が正の整数でない。 |
リクエストを直す。 |
ORD-005 |
400 | 1 回の注文の商品が多すぎる(最大 100)。 | 注文を分ける。 |
ORD-006 |
401 | アクセストークンがないか、期限切れ。 | ログインし直す。 |
ORD-007 |
402 | 決済が拒否された。 | 別の支払い方法を選んでもらう。 |
ORD-008 |
402 | カードの有効期限が切れている。 | 別のカードを選んでもらう。 |
ORD-009 |
402 | 3-D セキュアの認証に失敗した。 | 認証をやり直す。 |
ORD-010 |
403 | アカウントが停止されている。 | 問い合わせ先を表示する。 |
ORD-011 |
404 | カートが存在しない。 | カートを読み直す。 |
ORD-012 |
404 | 配送先が存在しない。 | 配送先を選び直してもらう。 |
ORD-013 |
404 | 支払い方法が存在しない。 | 支払い方法を選び直してもらう。 |
ORD-014 |
409 | 在庫切れの商品がある。 | 在庫数を表示して選び直してもらう。 |
ORD-015 |
409 | 注文確定の途中でカートが変わった。 | 新しいカートを表示する。 |
ORD-016 |
409 | Idempotency-Key が別のカートで使われている。 |
新しいキーを作る。 |
ORD-017 |
422 | 配送先が配送エリアの外にある。 | 別の配送先を選んでもらう。 |
ORD-018 |
422 | 合計金額が最低注文金額に満たない。 | 最低注文金額を表示する。 |
ORD-019 |
429 | リクエストが多すぎる。 | Retry-After の後にリトライする。 |
ORD-020 |
502 | 決済ゲートウェイが応答しなかった。 | 注文一覧を確かめてからリトライする。 |
ORD-021 |
503 | 在庫サービスが利用できない。 | 時間をおいてリトライする。 |
JSON の本文の項目。子の項目の「必須」は、親を送るときに必須かを表す。
| No | 階層 | 項目名物理名 | 物理名 | 型 | 必須 | 説明 |
|---|---|---|---|---|---|---|
| 1 | 1 | カート IDcartId |
cartId |
string | ○ | 注文するカート。ログイン中の顧客のもので、まだ注文していないこと |
| 2 | 1 | 配送shipping |
shipping |
object | ○ | |
| 3 | 2 | 配送先 IDaddressId |
addressId |
string | ○ | 顧客の登録済みの住所。配送エリア外の住所は受け付けないので、注文画面では配送できない住所をグレーアウトして選べなくする |
| 4 | 2 | 配達日deliveryDate |
deliveryDate |
string | YYYY-MM-DD。省略時は最短の日。予約が埋まっている日と 14 日より先の日は受け付けない |
|
| 5 | 2 | 時間帯timeSlot |
timeSlot |
string | am・pm・evening。省略時は指定なし。日曜日は evening を選べない |
|
| 6 | 1 | 支払いpayment |
payment |
object | ○ | |
| 7 | 2 | 支払い方法 IDmethodId |
methodId |
string | ○ | 登録済みのカードやウォレット。有効期限切れや前回のオーソリで拒否されたカードは、顧客が更新するまで使えない |
| 8 | 2 | クーポンコードcouponCode |
couponCode |
string | 1 回の注文に 1 枚まで。期限切れのクーポンは適用せず、理由をレスポンスで返す | |
| 9 | 1 | 商品items |
items |
array | ○ | 1〜100 件。同じ SKU は 1 件にまとめ、合計を quantity で送る |
| 10 | 2 | SKUsku |
sku |
string | ○ | |
| 11 | 2 | 数量quantity |
quantity |
integer | ○ | 1〜99。在庫と商品ごとの購入数の上限のうち、少ない方までに制限する |
| 12 | 2 | ギフト包装giftWrap |
giftWrap |
boolean | 省略時は false。包装は商品ごとに有料で、家具など一部の商品では選べない |
注文確定のシーケンスの 9 で表示する画面。ワイヤーフレームの番号は下の項目の No と対応する。
<html lang="ja"><head>
<title>注文完了</title>
<style>/* Shared styles of the wireframes: a plain, gray look, so that they read as mock-ups rather than finished screens. */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", Meiryo, "Noto Sans JP", sans-serif;
font-size: 14px;
color: #333;
background: #fafafa;
}
header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #e0e0e0;
font-weight: bold;
}
header img { width: 24px; height: 24px; }
main { padding: 20px 16px; }
h1 { margin: 0 0 16px; font-size: 20px; }
label { display: block; margin-bottom: 12px; }
label span { display: block; margin-bottom: 4px; color: #666; font-size: 12px; }
input[type="text"],
input[type="password"] {
box-sizing: border-box;
width: 100%;
padding: 8px;
border: 1px solid #999;
border-radius: 4px;
background: #fff;
}
.check { display: flex; align-items: center; gap: 6px; }
.check span { display: inline; margin: 0; font-size: 14px; color: #333; }
button {
width: 100%;
padding: 10px;
border: none;
border-radius: 4px;
background: #666;
color: #fff;
font-size: 15px;
}
.error {
margin-bottom: 12px;
padding: 8px;
border: 1px dashed #c00;
color: #c00;
font-size: 12px;
}
.links { margin-top: 16px; text-align: center; }
a { color: #333; }
.done { margin-bottom: 16px; padding: 12px; border-radius: 4px; background: #e8e8e8; text-align: center; }
.done strong { display: block; margin-top: 4px; font-size: 18px; letter-spacing: 1px; }
dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; margin: 0 0 16px; }
dt { color: #666; }
dd { margin: 0; text-align: right; }
.items { margin: 0 0 12px; padding: 0; list-style: none; border-top: 1px solid #ccc; }
.items li { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #ccc; }
.total { font-weight: bold; }
</style>
</head>
<body>
<header><img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHJ4PSI1IiBmaWxsPSIjODg4Ii8+PHBhdGggZD0iTTcgMTJsMyAzIDctNyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuNSIvPjwvc3ZnPgo=" alt=""> Example Shop</header>
<main>
<div class="done">ご注文ありがとうございました。<strong data-item="1">ORD-2026-000123</strong></div>
<ul class="items">
<li data-item="2"><span>デスクライト × 1</span><span>¥5,800</span></li>
<li data-item="2"><span>LED 電球 × 2</span><span>¥1,760</span></li>
</ul>
<dl>
<dt>合計</dt><dd class="total" data-item="3">¥7,560</dd>
<dt>お届け予定</dt><dd data-item="4">10月28日 午前</dd>
</dl>
<button data-item="5">買い物を続ける</button>
<p class="links"><a href="#" data-item="6">注文履歴を見る</a></p>
</main>
</body></html>| No | 項目名物理名 | 物理名 | 種別 | 説明 |
|---|---|---|---|---|
| 1 | 注文番号orderNo |
orderNo |
テキスト | POST /orders のレスポンスの注文番号。 |
| 2 | 注文商品items |
items |
リスト | SKU ごとに 1 行。数量と小計を表示する。 |
| 3 | 合計total |
total |
テキスト | 税込み・送料込み。 |
| 4 | お届け予定delivery |
delivery |
テキスト | 選んだ日付と時間帯。 |
| 5 | 買い物を続けるcontinue |
continue |
ボタン | |
| 6 | 注文履歴を見るorders |
orders |
リンク |
トップページを開く。カートはこの時点で空になっている。
注文履歴を開く。この注文が先頭に表示される。
返金はお届けから 30 日以内に申請できる。
確定した売上を元の支払い方法に返金する。
返金が反映されるまで最大 10 日かかることを顧客に伝える。
返品された商品を検品し、在庫に戻す。
Tip
ローカルで注文確定を試すには、docker compose up -d payment-mock でゲートウェイのモックを起動し、カード 4242 4242 4242 4242 を使う。
先にオーソリしてから在庫が切れると、オーソリを取り消すことになり、銀行によっては数日間それが顧客に見えてしまう。
Caution
承認番号やカード番号は、一部であってもログに出さない。