stripe-credit-audit-trail について
Stripe を使うアプリで、監査可能なクレジット残高の変更を実装する開発者向けスキルです。取引記録、冪等性、返金処理、残高照合の方法を扱います。
主な機能
- 01クレジット取引台帳の項目
- 02冪等キーの設計パターン
- 03共通の返金ヘルパー
- 04残高差異を調べる定期処理
- 05控除結果を示す明示的なフラグ
活用シーン
- 01クレジットの付与と控除の記録
- 02安全な返金フローの構築
- 03残高差異の監査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ josiahsiegel/ claude-plugin-marketplace --skill stripe-credit-audit-trailインストール後、Claude Code で/stripe-credit-audit-trailを入力して読み込まれたか確認します。
詳細
クイックリファレンス
| フィールド | 目的 |
|---|---|
userId | 取引の対象者 |
delta(符号付き整数) | +付与/-取り消し |
balanceAfter(整数のスナップショット) | 照合の基準値 |
reason(列挙型) | 固定された選択肢のいずれかを示すカテゴリ |
referenceType + referenceId | 元となるエンティティへのポインター |
idempotencyKey(UNIQUE 部分インデックス) | 重複排除 + G1 チェックポイント |
metadata(jsonb) | 自由形式のコンテキスト(previousStatus、resolvedVia など) |
課金対象のエンティティ(order、job、item など)に合ったドメインプレフィックスを1つ選び、一貫して使用してください。以下の表では、中立的な例として order を使用しています。
| 冪等性キーの形式 | 使用するタイミング |
|---|---|
debit:{orderId} | order 作成時のデビット |
refund:{orderId} | order の返金 |
debit:batch:{batchId} | バッチのデビット |
refund_unused:batch:{batchId} | バッチの一部返金 |
stripe_checkout:{sessionId} | クレジットパックの付与 |
stripe_invoice:{invoiceId} | サブスクリプションの更新 |
stripe_refund:{eventId} | Stripe の返金イベント |
stripe_signup_bonus:{userId} | サインアップボーナス |
dispute_hold:{disputeId} | 異議申し立て開始時の past_due 保留 |
dispute_restore:{disputeId} | 異議申し立て終了時のステータス復元 |
このスキルを使用する場面
残高/クレジット/利用権限のカラムを変更する処理がある場合、照合または監査用 cron を設計する場合、返金ヘルパーを拡張する場合は、必ず使用してください。
関連スキル:
- 異議申し立て保留のライフサイクルと
shouldRestoreStatusについて:stripe-billing-master:stripe-refund-dispute-lifecycle - 残高の変更すべてに先行する G1 チェックポイントについて:
stripe-billing-master:stripe-webhook-idempotency - 金銭関連のメール表示を制御する
resolvedViaフラグについて:stripe-billing-master:stripe-list-pagination-previous-attributes
標準的な返金ヘルパーのパターン
以下の例では、
orders.amount_usedは、その order で使用したクレジットの order ごとの台帳です。返金時にこれをゼロにすると、使用済みの金額がRETURNING経由で返され、同じトランザクション内でusers.credit_balanceに再加算されます。orders/amount_usedは、課金対象エンティティのスキーマに合わせて名前を変更してください。
ヘルパーは1つ。呼び出しパターンは2つ(Cloudflare Workers/Node workers では pg-raw、Next.js の route handlers では Drizzle)。バイト単位で同一の SQL は、エクスポートされた定数として保持します。以下の例では、orders は課金対象エンティティのテーブル名を表します。ヘルパー、キーの形式、すべての呼び出し箇所で一貫して名前を変更してください。
// example path: packages/shared/src/refund-credits.ts
export const REFUND_ORDER_SQL = {
zeroOrder: `UPDATE orders SET amount_used = 0 WHERE id = $1 AND amount_used > 0 RETURNING amount_used`,
creditUser: `UPDATE users SET credit_balance = credit_balance + $1 WHERE id = $2 RETURNING credit_balance`,
insertTransaction: `INSERT INTO credit_transactions (user_id, delta, balance_after, reason, reference_type, reference_id, idempotency_key)
VALUES ($1, $2, $3, 'order_refunded', 'order', $4, $5)
ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING`,
};
type RefundArgs = { userId: string; orderId: string };
type RefundResult = { refunded: number; alreadyRefunded: boolean };
// pg-raw variant (Workers, Node workers, any pg.Client / pg.Pool compatible driver).
export async function refundOrderCreditsPg(
pg: { query: (sql: string, params: unknown[]) => Promise<{ rows: any[] }> },
{ userId, orderId }: RefundArgs,
): Promise<RefundResult> {
await pg.query("BEGIN", []);
try {
const zeroed = await pg.query(REFUND_ORDER_SQL.zeroOrder, [orderId]);
const returnedAmount: number = zeroed.rows[0]?.amount_used ?? 0;
if (returnedAmount <= 0) {
await pg.query("COMMIT", []);
return { refunded: 0, alreadyRefunded: true };
}
const credited = await pg.query(REFUND_ORDER_SQL.creditUser, [returnedAmount, userId]);
const creditedBalance: number = credited.rows[0].credit_balance;
await pg.query(REFUND_ORDER_SQL.insertTransaction, [
userId,
returnedAmount, // delta (+returnedAmount)
creditedBalance, // balance_after snapshot
orderId, // reference_id
`refund:${orderId}`, // idempotency_key
]);
await pg.query("COMMIT", []);
return { refunded: returnedAmount, alreadyRefunded: false };
} catch (err) {
await pg.query("ROLLBACK", []);
throw err;
}
}
// Drizzle variant (Next.js route handlers / any Drizzle tx). Caller passes tx + sql tag.
export async function refundOrderCreditsDrizzle(
tx: { execute: (q: unknown) => Promise<{ rows: any[] }> },
sql: { raw: (s: string, ...p: unknown[]) => unknown },
{ userId, orderId }: RefundArgs,
): Promise<RefundResult> {
const zeroed = await tx.execute(sql.raw(REFUND_ORDER_SQL.zeroOrder, orderId));
const returnedAmount: number = zeroed.rows[0]?.amount_used ?? 0;
if (returnedAmount <= 0) {
return { refunded: 0, alreadyRefunded: true };
}
const credited = await tx.execute(sql.raw(REFUND_ORDER_SQL.creditUser, returnedAmount, userId));
const creditedBalance: number = credited.rows[0].credit_balance;
await tx.execute(
sql.raw(
REFUND_ORDER_SQL.insertTransaction,
userId,
returnedAmount, // delta (+returnedAmount)
creditedBalance, // balance_after snapshot
orderId, // reference_id
`refund:${orderId}`, // idempotency_key
),
);
return { refunded: returnedAmount, alreadyRefunded: false };
}
呼び出し箇所:ユーザーに代わって返金するすべてのサービス、キューコンシューマー、worker のエントリーポイントは、必ずこのヘルパーに処理を委譲してください。インライン SQL で新しい返金の呼び出し箇所を追加しないでください。そうすると、一部の分岐で監査行が欠落し、照合 cron が信頼できる情報源ではなくなるためです。
照合 cron
-- Snapshot comparison, no full sum
WITH latest AS (
SELECT DISTINCT ON (user_id) user_id, balance_after, created_at
FROM credit_transactions
ORDER BY user_id, created_at DESC
)
SELECT u.id, u.credit_balance AS actual, l.balance_after AS expected, u.credit_balance - l.balance_after AS drift
FROM users u
JOIN latest l ON l.user_id = u.id
WHERE u.credit_balance <> l.balance_after;
アラートのみのポリシーとし、自動修正は決して行わないでください。テーブル導入後に credit_transactions の行が1件もないユーザーには基準値がないため、対象から除外してください。
creditsDeducted ブール値パターン
クレジットの差し引きとキューへの送信を、それぞれ別の try/catch ブロックに分けます。戻り値には常に creditsDeducted: boolean を含め、呼び出し元が次のように判別できるようにします。
- 差し引き前に DB エラーが発生 →
creditsDeducted=false、呼び出し元は返金をスキップ - 差し引き後に DB エラーが発生 →
creditsDeducted=true、呼び出し元は標準ヘルパーを使って返金 - キューへの送信に失敗 →
creditsDeducted=true、呼び出し元は返金
返金の実行可否を order 行の amount_used カラムで判断しないでください。この値は UPDATE の成功前に設定されるため、エラー後に読み取ると、実際にはコミットされなかった差し引きに対して無料クレジットを付与する可能性があります。差し引き関数が返す明示的な creditsDeducted フラグを使用してください。