明細を隠せるUIを持つなら、合計は表示している行から導いてはいけません。文字にすると当たり前に見えます。しかしコンポーネントを書いている最中には当たり前ではなく、これは「隠すよう求められたまさにその数字をシステムが漏らす」という一群のバグの根にあります。
この記事では、出会うまでは無関係に見える二つの機能、それらを誠実に保つ不変条件、そしてそれでも我々が間違えた場所について書きます。
前提
私たちはShopify向けのBtoB見積アプリを作っています。互いを想定していない二つの機能が、思わぬ形で噛み合います。
- 明細ごとの価格表示。 マーチャントは見積の明細を価格非表示にでき、バイヤーには金額の代わりに「込み」と表示されます。こちらで負担する送料、同梱するサンプル、別の明細に織り込んだ作業などです。
- 一部承認。 バイヤーは提案の一部の明細を承認し、別の明細を辞退し、残りを保留にできます。
どちらもバイヤーに見える行を変えます。しかしどちらもバイヤーが支払うべき額を変えてはいけません。
あなたが書くことになるバグ
合計ブロックの自然な実装は、描画している行に対するreduceです。
const subtotal = visibleLines.reduce((acc, l) => acc + l.lineTotal, 0)
ここで500ドルの明細を隠します。小計は静かに500ドル下がり、バイヤーには送られた見積と一致しない合計が見えます。そこで分かりやすい方法で直します。すべての行を合計し、一部だけを描画するのです。
const subtotal = allLines.reduce((acc, l) => acc + l.lineTotal, 0) // まだ間違い
これで合計は正しく、明細は隠れました。そしてバイヤーは引き算一回で隠された価格を復元できます。見えている行と印字された合計の差こそが、隠した数字だからです。作ったのは機密性ではなく、謎かけです。
規則
バイヤーに示す合計は、保存済みの見積金額から取る。描画した行からは決して取らない。
正となる数字は、提案を送信した時点で書き込まれます。ストアフロント、顧客アカウントの拡張、ホスト型ポータル、四種類のPDFテンプレート。どの面もその保存値を読み、どの面も再計算しません。
これは必要ですが十分ではありません。それだけでは漏れる場所が移るだけだからです。保存された合計に隠した明細の価格が含まれていれば、引き算は依然として成立します。そこで、この規則には対になる制約が要ります。
価格非表示の明細は0でなければならない。送信前に強制する。
これは結局、この機能の誠実な形でもあります。「価格非表示」が意味しうるのはこれ以上の費用はかかりませんだけです。いくらかかるかは申し上げませんという意味にはなりえません。見えている合計を動かす明細は隠されておらず、ぼかされているだけで、バイヤーは計算ができます。だから私たちは、あとで謝る羽目になるものを描画するのではなく、送信の時点で提案を差し戻します。
二つの制約を合わせると、不変条件として言葉にする価値のある性質が得られます。
見えている行は、常に印字された合計になる。 おおむねではなく、厳密に。
それでも一部承認がこの不変条件を壊した
ここからが、我々が間違えた側で、面白い半分です。
バイヤーが見積の一部を承認したとき、注文確認書には承認された明細が出るべきです。明細表の範囲を絞り、承認済み合計の値を足し、テストを書いて、リリースしました。
出てきた書類は、1,400ドル分の承認済み明細の表の上に、確定合計 1,825.00ドルという見出しを掲げていました。
合計のブロックは更新されていました。その上に描画されるヘッダーは、別のコンポーネントで、承認がまだ全部か無かだった時期に書かれており、更新されていませんでした。自分の理屈では正しく見積全体の合計を読み、数センチ下の表と矛盾していたのです。
不変条件は、我々が考えていた場所では保たれ、考えていなかった場所で壊れました。同じ事実を読む二つ目の読み手が、数か月前に書かれたまま、静かに古びていたわけです。
一般化する直し方
「ヘッダーも直す」ではありません。見出しの数字を使うすべての側が、必ず通る関数を一つ作ります。
export function documentHeadlineTotal(quote: QuoteContext): {
label: "Accepted total" | "Confirmed total"
amount: string
} {
return quote.acceptedTotals
? { label: "Accepted total", amount: quote.acceptedTotals.subtotal }
: { label: "Confirmed total", amount: quote.version.totalEstimate }
}
ラベルと金額は一緒に選ばれ、一緒に返ります。二つが一つの値として届くため、承認済みの金額だけを受け取って見積全体のラベルを残す、ということができません。バグの種類そのものを、パッチではなく設計で消しています。
一つだけ持ち帰るなら、これです。二つの値が必ず一致しなければならないなら、同じ関数から返す。 同じ元データを読む二つの呼び出し箇所は、片方が編集された瞬間にずれます。
他のチームに伝えたいこと
- 導出値の意味を変える前に、その値を使っている箇所をすべて洗い出す。 我々は合計コンポーネントは検索しましたが、書類のヘッダーは見ませんでした。「合計を描画しているのは誰か」の棚卸しがあれば、五分で見つかっていました。
- 描画時ではなく境界で強制する。 価格0の制約は送信時に検査します。描画の時点では、嘘をつくか落ちるかしかありません。
- 存在ではなく算術をテストする。 「承認済み合計が表示されている」という表明は、ヘッダーがそれと矛盾していても軽々と通ります。見えている行が印字された数字になることを検証してください。
- テストではPDFを画像化する。 テキスト抽出は、正しい文字列があると教えてくれました。実際にありました。ただし間違った場所にあり、組み合わせとして間違ったことを言っていました。それを示せたのは画像だけです。
最終的な挙動は実際の見積で確認しました。1400 + 350 + 75 + 0(最後の一つが価格非表示)の四明細、保存された合計1825、二明細を承認したバイヤー、そして承認済み合計 1,400.00を見出しに掲げ、まさにその二明細の表を載せた注文確認書。見えている行が印字された合計になる。テストはそれだけです。
Shopifyアプリ開発者への補足
顧客アカウントのUI拡張の上に作っているなら、関連する落とし穴があります。64KBのバンドル上限のために、文言をバンドルに含めず実行時に取得したくなります。それは妥当です。ただしその場合、文言のキーが正当に欠けうるようになります。そして strings.someLabel.replace("{count}", n) はTypeErrorを投げ、拡張はクラッシュし、ホスト側のスピナーはエラーも出ないまま永遠に回り続けます。null安全なヘルパー経由で差し込んでください。なぜ知っているのかは聞かないでください。
製品ではどこに現れるか
ここで説明した二つの機能はどちらも本番で動いています。明細ごとの価格表示と一部承認はProfessionalプラン以上です。同じ仕組みのマーチャント側の説明は交渉と提案、商談上の意味は一部承認、どのプランに何が含まれるかは料金をご覧ください。
よくある質問
なぜ表示中の行から合計を計算してはいけないのですか?
明細を隠せるUIでは、一致しない二つの数字が生まれるからです。見えている行だけを合計すると、明細を隠した時点で合計が下がり、送った見積と矛盾します。すべての行を合計して一部だけを表示すると、見えている行と印字された合計の差が、そのまま隠した価格になります。
なぜ価格非表示の明細は0でなければならないのですか?
そうでなければ、そもそも隠せていないからです。隠した明細が見えている合計を動かすなら、その価格は引き算で復元できます。0という制約こそが、隠すことを誠実な表明に変えます。「込み」とは、その明細にバイヤーの追加負担がないという意味です。
関連記事
QuotWayがこれをあなたのストアでどう扱うかをご覧ください。