Gumroad 会计与报表脚本实战指南:在生产控制台运行财务报告任务
【免费下载链接】gumroadSee what sticks项目地址: https://gitcode.com/GitHub_Trending/gumr/gumroad
本文基于 Gumroad 开源仓库的会计与报表运维文档,完整讲解在 Rails 生产控制台(production console)中运行财务报告脚本的操作方法与底层实现。Gumroad 的财务团队依赖这批脚本生成未结余额报告、美国各州销售税月度报告、全美汇总报告以及按国家/自定义日期区间导出的销售报表。读完本文,你将掌握每一个报告的触发命令、参数含义、异步 Job 的工作机制,以及它们与 TaxJar、Stripe、S3 和 Sidekiq 的完整协作链路,可直接照搬到自己的生产环境执行。
运行前提:在哪个环境、用什么方式执行
原文档明确说明,这批脚本面向的是生产控制台——即通过rails console(生产环境)进入的应用交互环境,而不是普通 Web 请求路径。执行方式分两类:
| 报告 | 执行方式 | 特点 |
|---|---|---|
| 未结余额报告(outstanding balances) | 同步长任务,deliver_now | 需在长驻 Web/控制台进程内运行,建议配合终端复用器(terminal multiplexer,如 tmux/screen)防止 SSH 断连导致任务中断 |
| 美国各州月度报告、全美汇总报告、国家销售报告 | 异步 Job,perform_async | 由 Sidekiq 后台队列执行,控制台只负责入队,无需长驻进程 |
后三类报告在仓库中均有对应的 Sidekiq Job 实现,位于 app/sidekiq 目录下,统一使用lock: :until_executed互斥锁,保证同一报告不会并发重复执行。
报告一:邮件发送未结余额报告(Outstanding Balances)
这是一份面向财务人员的应收账款快照:统计所有持有非零未付余额(unpaid balance)的用户,按 PayPal 与 Stripe 两条资金渠道区分余额归属,最终生成 CSV 附件发送到指定邮箱。
注意:该脚本运行时间长,应在长驻的 Web 服务器实例中运行,最好配合终端复用器。
原文档给出的完整可运行脚本如下(实际生产环境中需要把最后一行mail.to改为目标邮箱,并以AccountingMailer.email_outstanding_balances_csv.deliver_now触发):
# Change the mail.to in the last line and run as: AccountingMailer.email_outstanding_balances_csv.deliver_now require "csv" class AccountingMailer < ApplicationMailer def email_outstanding_balances_csv @balance_stats = { stripe: { held_by_gumroad: { active: 0, suspended: 0 }, held_by_stripe: { active: 0, suspended: 0 } }, paypal: { active: 0, suspended: 0 } } balances_csv = CSV.generate do |csv| csv << ["user id", "paypal balance (in dollars)", "total stripe balance (in dollars)", "stripe balance held by gumroad (in dollars)", "stripe balance held by stripe (in dollars)", "stripe account", "stripe account currency", "stripe balance held by stripe (in holding currency)", "actual stripe account balance (in holding currency)", "current fx rate", "actual stripe account balance (converted in dollars)", "is_suspended", "user_risk_state", "tos_violation_reason"] User.holding_non_zero_balance.find_each(batch_size: 1000) do |user| stat_key = user.suspended? ? :suspended : :active if (user.payment_address.present? || user.has_paypal_account_connected?) && user.active_bank_account.nil? @balance_stats[:paypal][stat_key] += user.unpaid_balance_cents csv << [user.id, user.unpaid_balance_cents / 100.0, 0, 0, 0, nil, nil, 0, 0, nil, 0, user.suspended?, user.user_risk_state, user.tos_violation_reason] else balances = user.unpaid_balances balances_by_holder_of_funds = balances.group_by { |balance| balance.merchant_account.holder_of_funds } balances_held_by_gumroad = balances_by_holder_of_funds[HolderOfFunds::GUMROAD] || [] balances_held_by_stripe = balances_by_holder_of_funds[HolderOfFunds::STRIPE] || [] @balance_stats[:stripe][:held_by_gumroad][stat_key] += balances_held_by_gumroad.sum(&:amount_cents) @balance_stats[:stripe][:held_by_stripe][stat_key] += balances_held_by_stripe.sum(&:amount_cents) stripe_account_id = stripe_account_currency = fx_rate = nil stripe_account_balance_in_holding_currency = actual_stripe_account_balance = 0 if balances_held_by_stripe.last.present? stripe_account_id = balances_held_by_stripe.last.merchant_account.charge_processor_merchant_id stripe_account_currency = balances_held_by_stripe.last.merchant_account.currency stripe_account_balance_in_holding_currency = balances_held_by_stripe.select{ _1.holding_currency == balances_held_by_stripe.last&.merchant_account&.currency }.sum(&:holding_amount_cents) fx_rate = 1 if stripe_account_currency.downcase == "usd" if fx_rate.blank? balance_transaction = BalanceTransaction.where(holding_amount_currency: stripe_account_currency).where("holding_amount_net_cents != 0").where("issued_amount_net_cents != 0").last fx_rate = balance_transaction.holding_amount_net_cents * 1.0 / balance_transaction.issued_amount_net_cents if balance_transaction.present? end stripe_balance = Stripe::Balance.retrieve({ stripe_account: stripe_account_id }) rescue nil stripe_available_balance = stripe_balance["available"][0]["amount"] rescue 0 stripe_pending_balance = stripe_balance["pending"][0]["amount"] rescue 0 actual_stripe_account_balance = stripe_available_balance + stripe_pending_balance end csv << [user.id, 0, user.unpaid_balance_cents / 100.0, balances_held_by_gumroad.sum(&:amount_cents) / 100.0, balances_held_by_stripe.sum(&:amount_cents) / 100.0, stripe_account_id, stripe_account_currency, stripe_account_balance_in_holding_currency / 100.0, actual_stripe_account_balance / 100.0, fx_rate, fx_rate.present? ? (actual_stripe_account_balance / (100.0 * fx_rate)).round(2) : nil, user.suspended?, user.user_risk_state, user.tos_violation_reason] end end end attachments["outstanding_balances.csv"] = { data: ::Base64.encode64(balances_csv), encoding: "base64" } mail to: "hello@example.com", subject: "Outstanding balances" end end核心逻辑与源码印证
这份脚本并非"一次性临时代码"——仓库中的 AccountingMailer 本身就内置了email_outstanding_balances_csv方法(见 accounting_mailer.rb),生产版本默认把报告发给FINANCE_EMAIL并抄送gumclaw@gumroad.com,附件同样以 Base64 编码 CSV 形式挂载。文档中的版本是对其的完整扩展,额外补充了 Stripe 侧资金明细核对。
几个关键实现细节值得注意:
- 用户筛选范围:
User.holding_non_zero_balance是 user.rb 中定义的一个 scope,通过joins(:balances).merge(Balance.unpaid)联表查询,再按SUM(balances.amount_cents) != 0分组过滤,只保留未付余额非零的用户。分批遍历使用find_each(batch_size: 1000),避免大结果集一次性载入内存。 - 资金归属方(holder of funds):余额按
merchant_account.holder_of_funds分组,该枚举定义在 holder_of_funds.rb,取值为gumroad(Gumroad 代持)与stripe(Stripe 直连商户账户持有)。Gumroad 的商户结算有两种模式,未结余额也因此被拆成"held by gumroad"与"held by stripe"两个口径。 - PayPal 分支:当用户绑定了 PayPal 收款地址(
payment_address或has_paypal_account_connected?)但没有有效银行账户(active_bank_account.nil?)时,余额计入 PayPal 口径;否则进入 Stripe 口径统计。 - 汇率换算:对于非 USD 的 Stripe 账户,脚本从最近的
BalanceTransaction中取holding_amount_net_cents / issued_amount_net_cents作为当前外汇汇率(fx rate),并把 Stripe 账户实时余额(available + pending,来自Stripe::Balance.retrieve)换算成美元,用于与账面数据对账。 - 风控标记:每行 CSV 都附带
is_suspended、user_risk_state、tos_violation_reason三个字段,方便财务在看到异常余额时快速定位被风控/封禁的用户。
报告二:生成月度美国州销售报告(Monthly US State Report)
该报告针对单个州 + 单个月份,把该月内销往该州的应税交易整理成 TaxJar 口径的 CSV,并同步把每笔订单写入 TaxJar 平台。它在文档中明确指出通过异步 Job 执行,不需要长驻进程。
单州单月触发命令(示例为华盛顿州 2022 年 8 月):
CreateUsStateMonthlySalesReportsJob.perform_async("WA", 8, 2022)回填多个州、多个月份时,可以嵌套循环批量入队(示例回填 NV、TX、RI 三个州 2025 年 1~3 月):
states = ["NV", "TX", "RI"] months = [1, 2, 3] year = 2025 states.each do |state| months.each do |month| CreateUsStateMonthlySalesReportsJob.perform_async(state, month, year) end end底层实现:CreateUsStateMonthlySalesReportsJob
- 参数校验:
perform(subdivision_code, month, year)会先校验州代码是否存在于Compliance::Countries::USA.subdivisions,非法州代码直接抛ArgumentError;月份限定1..12,年份限定2014..3200(源码第 14-16 行)。 - 交易筛选口径:选取
Purchase.successful、not_fully_refunded、not_chargedback_or_chargedback_reversed、stripe_transaction_id非空、且创建时间落在目标月份内的交易,并通过"国家=United States"或"国家为空且 IP 国家=United States"双重条件定位美国买家(源码第 40-49 行)。 - 州的判定:优先使用订单 ZIP code 查
UsZipCodes.identify_state_code匹配州代码;ZIP 缺失时回退到GeoIp.lookup(ip_address)的region_name(源码第 50-60 行)。无法解析出该州内 ZIP 的交易会被丢弃——因为 TaxJar 的目的地税额计算必须依赖 ZIP(源码第 75-77 行)。 - CSV 列结构:共 17 列,包含
Purchase External ID、Purchase Date、Member State of Consumption、Total Transaction、Price、Tax Collected by Gumroad、Combined Tax Rate、Calculated Tax Amount、三级司法辖区(State/County/City)及其税率、Amount not collected by Gumroad、Gumroad Product Type与TaxJar Product Tax Code。 - TaxJar 同步:对有
purchase_taxjar_info的订单直接复用其缓存税率;否则调用taxjar_api.calculate_tax_for_order实时计算,再调用create_order_transaction把订单写入 TaxJar。对Taxjar::Error::UnprocessableEntity(已存在的事务)与BadRequest做了降级处理,不阻断整批任务(源码第 140-155 行)。 - 产物交付:CSV 上传到
REPORTING_S3_BUCKET的sales-tax/<州名>/路径,生成一周内有效的 S3 预签名下载链接,并通过InternalNotificationWorker发送到内部 payments 频道通知(源码第 162-174 行)。
报告三:生成全美各州月度汇总报告(US States Sales Summary)
当需要对所有应税州的某一个月份做整体汇总时,使用CreateUsStatesSalesSummaryReportJob。文档给出的命令是:
subdivision_codes = Compliance::Countries::TAXABLE_US_STATE_CODES CreateUsStatesSalesSummaryReportJob.perform_async(subdivision_codes, 3, 2024)Compliance::Countries::TAXABLE_US_STATE_CODES是仓库中预置的"应税州代码集合",直接传入即可覆盖全部需要申报销售的州;示例为 2024 年 3 月。
底层实现:CreateUsStatesSalesSummaryReportJob
- CSV 结构:输出仅为 4 列——
State、GMV(商品交易总额)、Number of orders(订单数)、Sales tax collected(实收销售税)。它把报告二那种逐笔明细收敛为州级汇总。 - 四条"腿"(leg)的合并计算:这是该 Job 最值得注意的会计逻辑——汇总不只是加总当月订单,而是由四部分组成(源码第 31-64 行):
- 订单腿:当月创建的应税订单,累加 GMV 与税;
- 退款腿:当月发生的退款(按退款日期归属当月,与原始购买时间无关),从 GMV 和税中扣减,但订单数不变;
- 拒付腿:当月被正式发起拒付(chargeback)的交易,扣减 GMV 与税;
- 拒付逆转腿:当月胜诉(dispute won)的拒付,把金额加回。
- 可选推送到 TaxJar:
perform的第四个参数push_to_taxjar默认为false。如今 TaxJar 订单由每日任务(见下文)自动上传,月度任务只做汇总;如需手动回填/重推某个月,可传true走幂等推送。 - 失败告警:Job 声明了
sidekiq_retries_exhausted回调(重试 3 次耗尽后),通过AccountingMailer.us_states_sales_summary_report_failed发送失败邮件;若错误类以Taxjar::开头,邮件主题还会加上[TaxJar]前缀(见 accounting_mailer.rb)。 - 交付方式:汇总 CSV 上传至
sales-tax/summary/路径,同样附带一周有效期的 S3 下载链接并发送内部通知。
延伸:每日 TaxJar 上传任务与月度汇总的分工
从源码可以推断,UploadUsStatesSalesTaxToTaxjarJob 是这套报表体系的前置数据管道:它按每日粒度(无参数入队时默认处理昨天,见perform(date = Date.yesterday.iso8601))把当天的订单、退款、拒付和拒付逆转逐笔写入 TaxJar,将过去"月末集中推送一整月"的高风险模式(历史上曾因瞬时 DNS/网络错误导致整月数据推送中断)拆散为每天约 1/30 的体量。该 Job 与月度汇总共享 UsStateSalesTaxUploader 的选择逻辑、ZIP 解析与金额计算,保证"每日推送"与"月度报表"口径一致;TaxJar 侧创建事务是幂等的,因此重试或与手动重推重叠都是安全的。
报告四:按客户国家与自定义日期区间生成销售报告
这是最灵活的一个报告:可以针对任意国家代码 + 任意日期区间导出销售 CSV。文档明确说明:它通过异步 Job 执行、无需长驻进程,任务完成后会向 Slack 发送包含下载链接的通知。
典型用法——导出 2025 年 1 月所有日本客户的销售:
GenerateSalesReportJob.perform_async("JP", "2025-1-1", "2025-1-31", "all_sales")参数说明:"JP"为 ISO 3166-1 alpha-2 国家代码,"2025-1-1"与"2025-1-31"为起止日期字符串(闭区间),"all_sales"表示全部销售。若只想包含 Discover 渠道(站内推荐/发现流量)产生的销售,把最后一个参数换成"discover_sales",例如导出 2024 年 1 月 1 日至 2025 年 6 月 30 日新加坡客户的 Discover 销售:
GenerateSalesReportJob.perform_async("SG", "2024-1-1", "2025-6-30", "discover_sales")底层实现:GenerateSalesReportJob
- 参数校验:国家代码经
ISO3166::Country[country_code]校验,非法代码抛ArgumentError: Invalid country code;sales_type只接受SALES_TYPES = ["all_sales", "discover_sales"](源码第 9-15 行)。日期区间会被归一化为"起日 00:00:00 ~ 止日 23:59:59"的闭区间。 - Discover 过滤:当
sales_type == DISCOVER_SALES时,用位掩码purchases.flags & was_product_recommended筛出推荐流量订单;同时排除RecommendationType.is_free_recommendation_type?的免费推荐(如 library、more-like-this),因为这类推荐不产生 Discover/市场费用(源码第 44、60 行)。 - CSV 列:默认 13 列——
Sale time、Sale ID、Seller ID、Seller Email(邮箱前 4 位脱敏为####@)、Seller Country、Buyer Email、Buyer Card、Price、Gumroad Fee、GST、Shipping、Total、Customer Tax ID。针对澳大利亚(AU)与新加坡(SG)这两个实施 GST 的国家,还会追加Direct-To-Customer / Buy-Sell(实物 = DTC,数字 = BS)与Zip Tax Rate ID两列(源码第 54-56、171-184 行)。 - 四条"腿"的结构:与全美汇总类似,该报告也由销售腿、退款腿、拒付腿、拒付逆转腿四部分构成。退款按退款日期归属报告期、以负数列示;拒付按
purchases.chargeback_date(拒付正式确认时间)归属、净额列示;胜诉拒付按won_at加回(源码第 35-144 行)。 - 超时保护:整体查询包在
WithMaxExecutionTime.timeout_queries内,超时秒数默认 1 小时,可用 Redis 键RedisKey.generate_sales_report_job_max_execution_time_seconds动态调整(源码第 24-25 行)。 - 产物与通知:CSV 上传至
sales-tax/<国家小写>-sales-quarterly路径(支持s3_prefix自定义前缀),生成一周有效期的 S3 预签名 URL;完成后通过InternalNotificationWorker向内部 Slack 频道发送就绪通知,通知者按国家区分(AU/SG 显示 "GST Reporting",其余显示 "VAT Reporting")。 - 任务状态追踪:Job 会把最近 20 条任务记录写入 Redis(
RedisKey.sales_report_jobs),成功后把对应条目的状态从processing更新为completed并附上下载 URL,供内部管理页面查询(源码第 222-244 行)。
运维要点:重试、告警与报告取回
- 报告文件有效期:三个异步报告生成的 S3 预签名链接均默认一周内有效(
expires_in: 1.week.to_i),财务需要在期限内下载归档。 - 失败告警通道:
CreateUsStatesSalesSummaryReportJob(重试 3 次)与UploadUsStatesSalesTaxToTaxjarJob(重试 5 次)在重试耗尽后都会发邮件到PAYMENTS_NOTIFICATION_EMAIL;邮件正文会携带可复制的JobName.perform_async(...)重跑命令(见 accounting_mailer.rb 的finance_report_job_failed与 accounting_mailer.rb 的finance_report_delivery_backstop_triggered)。 - 互斥与幂等:所有报告 Job 都使用
lock: :until_executed防止同一参数重复排队;TaxJar 事务创建幂等,手动回填月份与每日自动上传重叠时不会产生重复数据。 - 控制台注意:未结余额报告是唯一需要"同步长驻"运行的脚本,务必放进 tmux/screen 等终端复用器;其余报告入队即返回,可在任意 Sidekiq 可用的环境执行。
总结
Gumroad 的会计与报表体系由 docs/accounting.md 这份运维文档定义入口,由四类任务构成完整闭环:AccountingMailer#email_outstanding_balances_csv负责资金对账快照,CreateUsStateMonthlySalesReportsJob负责州级明细 + TaxJar 同步,CreateUsStatesSalesSummaryReportJob负责全美月度汇总,GenerateSalesReportJob负责按国家/日期的灵活导出,并以 UploadUsStatesSalesTaxToTaxjarJob 的每日推送作为上游数据管道。这些任务在"订单—退款—拒付—拒付逆转"四腿会计口径、幂等推送、失败告警与 S3 交付等方面形成了高度工程化的报表基础设施,直接服务于税务申报与财务结算场景。
【免费下载链接】gumroadSee what sticks项目地址: https://gitcode.com/GitHub_Trending/gumr/gumroad
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考