PAYATHON 2026

如何通过 API 将 ChargeBee 订阅数据导入 Google Spreadsheet

支付阿杰

结论

可以直接通过 Google Apps Script 调用 Chargebee REST API,并将返回的数据写入 Google Spreadsheet,不需要单独部署服务器。

Customer name 和订阅金额可以从订阅及其关联的客户数据中获取。不过,“payment status”通常不属于订阅本身的状态。订阅状态反映订阅是否有效、暂停或取消,付款状态则要根据 Invoice(发票)或 Transaction(交易)数据判断。下面的示例将最新一张发票的状态作为付款状态。

开始前需要准备

开始前需要准备:

  • Chargebee Site Name。例如,站点地址为 https://acme.chargebee.com,Site Name 就是 acme
  • Chargebee API Key
  • 一个 Google Spreadsheet
  • 对客户、订阅和发票拥有读取权限的 Chargebee API Key

不要把 API Key 直接写入共享的 Spreadsheet 单元格或公开脚本。可以将它保存在 Apps Script 的 Script Properties 中。

Chargebee 的测试环境和正式环境使用不同的数据和 API Key,调试时应先使用测试站点。

保存 Chargebee 配置

打开目标 Google Spreadsheet,依次选择:

  1. 扩展程序
  2. Apps Script
  3. 左侧的“项目设置”
  4. 在“脚本属性”中添加以下属性
属性名示例值
CHARGEBEE_SITEacme
CHARGEBEE_API_KEYtest_xxxxxxxxxxxxx

CHARGEBEE_SITE 只需填写站点名称,不要填写完整 URL。

Apps Script 示例

将下面的代码粘贴到 Apps Script 编辑器中:

function importChargebeeSubscriptions() {
  const properties = PropertiesService.getScriptProperties();
  const site = properties.getProperty('CHARGEBEE_SITE');
  const apiKey = properties.getProperty('CHARGEBEE_API_KEY');

  if (!site || !apiKey) {
    throw new Error(
      '请先在 Script Properties 中配置 CHARGEBEE_SITE 和 CHARGEBEE_API_KEY。'
    );
  }

  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheetName = 'Chargebee Subscriptions';
  const sheet =
    spreadsheet.getSheetByName(sheetName) ||
    spreadsheet.insertSheet(sheetName);

  const subscriptions = getAllSubscriptions(site, apiKey);

  const rows = [[
    'Subscription ID',
    'Customer ID',
    'Customer Name',
    'Subscription Value',
    'Currency',
    'Subscription Status',
    'Payment Status',
    'Latest Invoice ID',
    'Updated At'
  ]];

  subscriptions.forEach(function (item) {
    const subscription = item.subscription || {};
    const customer = item.customer || {};
    const invoice = getLatestInvoice(
      site,
      apiKey,
      subscription.id
    );

    const customerName = getCustomerName(customer);
    const valueInMinorUnits = getSubscriptionValue(subscription);
    const currencyCode =
      subscription.currency_code ||
      (invoice && invoice.currency_code) ||
      '';

    rows.push([
      subscription.id || '',
      subscription.customer_id || customer.id || '',
      customerName,
      convertMinorUnits(valueInMinorUnits, currencyCode),
      currencyCode,
      subscription.status || '',
      invoice ? invoice.status || '' : 'NO_INVOICE',
      invoice ? invoice.id || '' : '',
      new Date()
    ]);
  });

  sheet.clearContents();

  if (rows.length > 0) {
    sheet
      .getRange(1, 1, rows.length, rows[0].length)
      .setValues(rows);
  }

  sheet.setFrozenRows(1);
  sheet.autoResizeColumns(1, rows[0].length);
}

function getAllSubscriptions(site, apiKey) {
  const results = [];
  let offset = null;

  do {
    let path = '/api/v2/subscriptions?limit=100';

    if (offset) {
      path += '&offset=' + encodeURIComponent(offset);
    }

    const response = chargebeeGet(site, apiKey, path);
    const list = response.list || [];

    Array.prototype.push.apply(results, list);
    offset = response.next_offset || null;
  } while (offset);

  return results;
}

function getLatestInvoice(site, apiKey, subscriptionId) {
  if (!subscriptionId) {
    return null;
  }

  const path =
    '/api/v2/invoices' +
    '?subscription_id%5Bis%5D=' + encodeURIComponent(subscriptionId) +
    '&sort_by%5Bdesc%5D=date' +
    '&limit=1';

  const response = chargebeeGet(site, apiKey, path);
  const list = response.list || [];

  return list.length > 0 ? list[0].invoice || null : null;
}

function chargebeeGet(site, apiKey, path) {
  const url = 'https://' + site + '.chargebee.com' + path;
  const authorization = Utilities.base64Encode(apiKey + ':');

  const response = UrlFetchApp.fetch(url, {
    method: 'get',
    headers: {
      Authorization: 'Basic ' + authorization,
      Accept: 'application/json'
    },
    muteHttpExceptions: true
  });

  const statusCode = response.getResponseCode();
  const body = response.getContentText();

  if (statusCode < 200 || statusCode >= 300) {
    throw new Error(
      'Chargebee API 请求失败。HTTP ' +
      statusCode +
      ',响应内容:' +
      body
    );
  }

  return JSON.parse(body);
}

function getCustomerName(customer) {
  const fullName = [
    customer.first_name || '',
    customer.last_name || ''
  ].join(' ').trim();

  return fullName ||
    customer.company ||
    customer.email ||
    customer.id ||
    '';
}

function getSubscriptionValue(subscription) {
  /*
   * 这里将“订阅价值”定义为:
   * 1. 优先使用 mrr;
   * 2. 没有 mrr 时使用 plan_amount;
   * 3. 再没有则使用 plan_unit_price × plan_quantity。
   *
   * 如果业务上的“订阅价值”是已付款金额,应改为读取 Invoice。
   */
  if (subscription.mrr != null) {
    return subscription.mrr;
  }

  if (subscription.plan_amount != null) {
    return subscription.plan_amount;
  }

  if (subscription.plan_unit_price != null) {
    return subscription.plan_unit_price *
      (subscription.plan_quantity || 1);
  }

  return null;
}

function convertMinorUnits(amount, currencyCode) {
  if (amount == null || amount === '') {
    return '';
  }

  /*
   * Chargebee 通常以货币最小单位返回金额。
   * 例如 USD 1000 通常表示 10.00 USD。
   * 不同货币的小数位数并不完全相同。
   */
  const zeroDecimalCurrencies = [
    'BIF', 'CLP', 'DJF', 'GNF', 'JPY',
    'KMF', 'KRW', 'PYG', 'RWF', 'UGX',
    'VND', 'VUV', 'XAF', 'XOF', 'XPF'
  ];

  const threeDecimalCurrencies = [
    'BHD', 'IQD', 'JOD', 'KWD', 'LYD',
    'OMR', 'TND'
  ];

  if (zeroDecimalCurrencies.indexOf(currencyCode) !== -1) {
    return amount;
  }

  if (threeDecimalCurrencies.indexOf(currencyCode) !== -1) {
    return amount / 1000;
  }

  return amount / 100;
}

首次运行 importChargebeeSubscriptions 时,Google 会请求访问 Spreadsheet 和外部网络的权限。完成授权后,脚本会创建或更新名为 Chargebee Subscriptions 的工作表。

API 调用的工作方式

Chargebee API 使用 HTTP Basic Authentication。API Key 作为用户名,密码留空:

Authorization: Basic base64(API_KEY:)

脚本先调用订阅列表接口:

GET https://{site}.chargebee.com/api/v2/subscriptions?limit=100

Chargebee 的列表接口会分页返回数据。如果响应中包含 next_offset,脚本会继续请求下一页,直至读取完所有订阅。

接着,脚本会为每个订阅查询最新发票:

GET /api/v2/invoices
    ?subscription_id[is]={subscription_id}
    &sort_by[desc]=date
    &limit=1

写入表格的主要字段来自以下数据:

表格字段数据来源
Customer Namecustomer.first_name、customer.last_name 或 customer.company
Subscription Valuesubscription.mrr、plan_amount 或计划单价与数量
Subscription Statussubscription.status
Payment Status最新一张 invoice.status

“订阅价值”需要先确定口径

“value of subscription”没有唯一的定义,常见口径有:

  • mrr:月度经常性收入,适合按月比较订阅价值
  • plan_amount:当前计划金额
  • 最新发票的 total:最近一次实际开票金额
  • 最新发票的 amount_paid:最近一次实际支付金额
  • 多张发票的累计支付金额:客户生命周期内的已付收入

示例默认优先导出 subscription.mrr。如果需要最新发票金额,可以将循环中的金额处理改为:

const valueInMinorUnits = invoice ? invoice.total : null;

如果需要实际到账金额,可以使用:

const valueInMinorUnits = invoice ? invoice.amount_paid : null;

字段是否出现在响应中,取决于 Chargebee 站点配置、产品目录模式和返回对象。正式使用前,应检查一次实际的 API 响应。

订阅状态不等于付款状态

不能直接将 subscription.status 用作 payment status。例如,订阅可能仍然有效,但最新发票可能正处于待付款或未付款状态。

发票状态可以根据业务需要解释为:

  • paid:发票已支付
  • payment_due:付款已到期或正在等待付款
  • not_paid:未支付
  • voided:发票已作废
  • pending:发票仍在处理中
  • NO_INVOICE:该订阅没有查询到发票

实际会出现哪些状态,应以当前 Chargebee API 的响应和所用 API 版本的文档为准。不要只根据这份列表写死所有业务规则。

数据量较大时的改进

示例会为每个订阅额外请求一次发票接口。假设有 1,000 个订阅,就可能产生约 1,001 次 API 请求,并可能超过 Google Apps Script 的单次运行时限。

数据量较大时,可以改为:

  1. 分页读取全部订阅;
  2. 分页读取所需时间范围内的发票;
  3. 在脚本中按 subscription_id 建立索引;
  4. 统一生成表格数据;
  5. 通过一次 setValues() 批量写入。

也可以把分页游标或最后同步时间保存到 Script Properties,只同步新增或更新的数据,不必每次都全量覆盖。

设置自动更新

如需定时同步,可以在 Apps Script 中创建触发器:

  1. 打开左侧“触发器”
  2. 添加触发器
  3. 选择函数 importChargebeeSubscriptions
  4. 将事件来源设为“时间驱动”
  5. 设置为每小时或每天运行

定时任务开始处理正式数据前,需要确认:

  • API Key 权限是否仅限读取
  • 表格中是否包含敏感的客户信息
  • 共享权限是否合理
  • 金额口径是否符合业务定义
  • 发票状态是否符合财务团队的判断规则
  • API 请求数量是否可能触及 Chargebee 或 Apps Script 的限制

问题中链接的 prod_cat_ver=1 表示文档按照特定产品目录模式展示,与示例请求路径中的 /api/v2/ 不是同一概念。如果站点使用其他产品目录模式,订阅中的计划、价格和订阅项字段可能不同,需要根据实际响应调整金额字段。

备注:内容仅供参考。