ほとんどのチェックアウトフローはこんな見た目をしている。顧客が詳細情報を入力し、確認をクリックし、決済ページにリダイレクトされ、戻ってくる。十分に直感的だ。しかし、その流れの中には構造的な問題が隠れている。注文は顧客が確認をクリックした瞬間に作成される。誰も実際にはまだ支払っていないのに。
つまり、放棄されたすべてのチェックアウト、失敗したすべての支払い、単に閉じられたすべてのブラウザタブが、データベースに実在するレコードを残すということだ。時間が経つにつれ、幽霊注文が積み上がっていく。実際の取引にはならなかったレコードだが、今や追跡し、期限切れにし、掃除しなければならない。もしシステムが注文作成時に在庫を割り当てる仕組みなら、それらの幽霊は他の誰にも売れない在庫を保持し続けていることになる。
支払い優先モデルは、この順序を反転させる。支払いが確認されるまで、注文は一切作成されない。実際にはどう見えるのか見ていこう。
カートスナップショット
注文を永続化する代わりに、注文を再構築するために必要なすべてのもののスナップショットを永続化する。これは支払いセッションと並んで存在し、支払いが(どちらの結果であれ)解決した時点で破棄される。
// checkout-session.entity.ts
@Entity()
export class CheckoutSession {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
session_token: string; // used as the txnid with the gateway
@Column('jsonb')
cart_snapshot: {
items: { product_id: number; variant_id: number; quantity: number; unit_price: number }[];
shipping_address: AddressSnapshot;
shipping_method: string;
shipping_fee: number;
voucher_ids: number[];
};
@Column({ nullable: true })
user_id: number;
@Column({ default: 'pending' })
status: 'pending' | 'paid' | 'failed' | 'expired';
@CreateDateColumn()
created_at: Date;
@Column({ type: 'timestamptz' })
expires_at: Date; // set to ~30 minutes from creation
}
このスナップショットは意図的に非正規化されたデータを保存する。価格、商品名、配送料は変わりうる。スナップショットが記録するのは、顧客がチェックアウト時点で同意した内容であり、データベースが今言っている内容ではない。
チェックアウトの開始
顧客がカートを確認すると、セッションを作成してゲートウェイに送る。この時点ではまだ注文はない。
// checkout.service.ts
async initiateCheckout(userId: number, input: CheckoutInput): Promise<{ checkoutUrl: string; sessionToken: string }> {
// Validate stock availability before creating the session
for (const item of input.items) {
const variant = await this.variantRepo.findOneOrFail({ where: { id: item.variant_id } });
if (variant.stock < item.quantity) {
throw new BadRequestException(`Insufficient stock for ${variant.sku}`);
}
}
const sessionToken = randomUUID();
const expiresAt = addMinutes(new Date(), 30);
await this.sessionRepo.save({
session_token: sessionToken,
user_id: userId,
status: 'pending',
expires_at: expiresAt,
cart_snapshot: {
items: input.items,
shipping_address: input.shipping_address,
shipping_method: input.shipping_method,
shipping_fee: input.shipping_fee,
voucher_ids: input.voucher_ids ?? [],
},
});
// Pass the session token as the transaction reference to the gateway
const checkoutUrl = await this.dragonpayService.initiate({
txnid: sessionToken,
amount: this.computeTotal(input),
description: `Order for user ${userId}`,
email: input.email,
});
return { checkoutUrl, sessionToken };
}
ここで在庫はチェックされているが、確保はされていない点に注目してほしい。これは意図的なトレードオフで、完了することのないセッションのために在庫を保持することを避けるためだ。欠点としては、2人の顧客が両方ともこのチェックを最後の1個に対して通過できてしまうことだ。決済コールバックがそのレースコンディションを明示的に処理する。
コールバック:実際に注文が作成される場所
ここで重みが移動する。既存の注文のステータスフラグを切り替える代わりに、コールバックはスナップショットから注文全体を再構築しなければならない。
// checkout.service.ts
async handlePaymentCallback(sessionToken: string, gatewayStatus: string): Promise {
const session = await this.sessionRepo.findOne({ where: { session_token: sessionToken } });
if (!session) throw new NotFoundException('Session not found');
if (session.status !== 'pending') return; // idempotency, duplicate callbacks do nothing
if (gatewayStatus !== 'S') { // DragonPay success code
session.status = 'failed';
await this.sessionRepo.save(session);
return;
}
// Re-validate stock at the moment of payment confirmation
for (const item of session.cart_snapshot.items) {
const variant = await this.variantRepo.findOneOrFail({
where: { id: item.variant_id },
lock: { mode: 'pessimistic_write' }, // row-level lock to handle concurrent callbacks
});
if (variant.stock < item.quantity) {
// Payment came in but stock is gone, needs manual resolution or refund trigger
session.status = 'failed';
await this.sessionRepo.save(session);
this.notifyStockConflict(session);
return;
}
variant.stock -= item.quantity;
await this.variantRepo.save(variant);
}
// Now create the order
const order = await this.orderRepo.save({
user_id: session.user_id,
status: 'confirmed',
shipping_address: session.cart_snapshot.shipping_address,
shipping_method: session.cart_snapshot.shipping_method,
shipping_fee: session.cart_snapshot.shipping_fee,
items: session.cart_snapshot.items.map(i => ({
product_id: i.product_id,
variant_id: i.variant_id,
quantity: i.quantity,
unit_price: i.unit_price,
})),
payment_reference: sessionToken,
});
// Apply vouchers if any
if (session.cart_snapshot.voucher_ids.length) {
await this.voucherService.applyToOrder(order.id, session.cart_snapshot.voucher_ids);
}
session.status = 'paid';
await this.sessionRepo.save(session);
}
8行目のべき等性チェックが非常に重要だ。ゲートウェイは同じポストバックを複数回発火させることがある。特にDragonPayはそうだ。これがないと、リトライ時に重複注文を作成してしまう。セッションのステータスがゲートとして機能する。一度pendingでなくなれば、コールバックは早期に終了する。
セッションの期限切れ
保留中のセッションは、スケジュールに従ってクリーンアップされる必要がある。NestJSのcronジョブがこれを処理する。
// checkout-expiry.task.ts
@Cron(CronExpression.EVERY_10_MINUTES)
async expirestaleSessions(): Promise {
await this.sessionRepo
.createQueryBuilder()
.update(CheckoutSession)
.set({ status: 'expired' })
.where('status = :status', { status: 'pending' })
.andWhere('expires_at < NOW()')
.execute();
}
期限切れのセッションは監査目的で保持される。注文テーブルを汚さずに、離脱率を確認するためにクエリすることができる。
このモデルが合わない場合
支払い優先モデルは、ゲートウェイがサーバーサイドのポストバックを発火し、それに基づいて動作できる場合にはきれいに機能する。DragonPayのモデルはこれによく適合する。PayMongoは異なる動き方をする。まず支払いインテントを作成し、顧客が支払い、その後Webhookが発火する。そのインテントは、すでに生成済みの注文IDに紐づけられているため、PayMongoは暗黙のうちに注文優先の方向へと誘導する。
両方のゲートウェイをサポートしている場合、いくつかの選択肢がある。チェックアウトセッションを普遍的な抽象化として使い、どちらのゲートウェイが発火したかに関係なくそこから注文を再構築することもできる。これが、PayMongoの記事で扱ったゲートウェイに依存しないポーリングエンドポイントを構築する価値を生む理由だ。あるいは、2つのゲートウェイが異なる経路で注文を生成することを受け入れ、代わりに注文レベルで整合性を取るという選択肢もある。
いずれにせよ、この決定について前もって意図的に考えておくことで、後になって多くの手戻りを避けられる。