关于 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 等),并始终保持一致。下表以 order 作为中性示例:
| 幂等键格式 | 使用场景 |
|---|---|
debit:{orderId} | 创建订单时扣款 |
refund:{orderId} | 订单退款 |
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} | 争议关闭后的状态恢复 |
何时使用此技能
凡是有任何路径会修改余额、额度或权益列,设计对账或审计定时任务,或扩展退款辅助函数时,都应使用此技能。
相关技能:
- 争议保留生命周期和
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是按订单记录已用额度的账本。退款时将其归零会返回已用额度(通过RETURNING),随后在同一事务中将额度重新计入users.credit_balance。请将orders/amount_used重命名为与计费实体架构匹配的名称。
一个辅助函数。两种调用模式(适用于 Cloudflare Workers / Node workers 的 pg-raw,以及适用于 Next.js 路由处理程序的 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 的退款调用点——否则某个分支可能遗漏审计行,导致对账定时任务不再具有权威性。
对账定时任务
-- 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 记录的用户没有基准;跳过这些用户。
creditsDeducted 布尔值模式
将额度扣减和队列提交拆分为独立的 try/catch 代码块。返回值始终包含 creditsDeducted: boolean,以便调用方区分:
- 扣减前发生 DB 错误 ->
creditsDeducted=false,调用方跳过退款 - 扣减后发生 DB 错误 ->
creditsDeducted=true,调用方通过规范辅助函数退款 - 队列提交失败 ->
creditsDeducted=true,调用方退款
绝不要根据订单行的 amount_used 列决定是否退款——它是在 UPDATE 成功前设置的,因此在错误后读取该值,可能会为实际未成功提交的扣减凭空发放额度。应依赖扣减函数返回的明确 creditsDeducted 标志。