mcpField
S
stripe-credit-audit-trail

stripe-credit-audit-trail

github.com/josiahsiegel开发5970 次安装19 周前更新
代码仓库

关于 stripe-credit-audit-trail

面向开发者的技能,介绍如何为 Stripe 应用实现可审计的积分余额变更,涵盖交易记录、幂等处理、退款与余额核对。

主要功能

  1. 01积分交易账本字段
  2. 02幂等键设计模式
  3. 03统一退款辅助函数
  4. 04余额偏差核对任务
  5. 05明确的扣款结果标记

适用场景

  1. 01记录积分发放与扣除
  2. 02构建安全退款流程
  3. 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 标志。

评论

支持 Markdown