PAYATHON 2026

Angular 集成 Square Payment API 和 Firebase serverless 支付

支付老李

结论

Angular 浏览器端不能直接调用 Square 的付款接口创建 charge。Square Access Token 必须保存在可信环境中。如果把它放进 Angular 代码、环境变量或网络请求,任何访问页面的人都可以将其提取出来。

合适的 serverless 方案如下:

  1. Angular 通过 Square Web Payments SDK 获取 nonce/payment token。
  2. Angular 将 token、订单编号和本次支付尝试 ID 发送给 Firebase Cloud Functions。
  3. Cloud Functions 从数据库读取订单并校验金额。
  4. Cloud Functions 使用安全保存的 Square Access Token 调用 Payments API。
  5. Cloud Functions 将支付结果写回 Firestore,再把必要信息返回给 Angular。

Firebase Cloud Functions 仍是服务端,只是无需自行购买和维护服务器,因此很适合用作支付场景中的 serverless 后端。

推荐架构

Angular
   │
   │ 1. Square SDK 生成 nonce/payment token
   ▼
Firebase Callable Function
   │
   │ 2. 验证登录用户、订单、金额和支付状态
   │ 3. 从 Secret Manager 读取 Square Access Token
   ▼
Square Payments API
   │
   │ 4. 返回支付结果
   ▼
Firestore / Angular

配置分为两类:

  • applicationId 和 locationId 可以提供给前端,用于初始化 Square Web Payments SDK。
  • Square Access Token 只能保存在 Cloud Functions 的 Secret Manager 中,不能写入 Angular 的 environment.ts、Firestore 或前端 HTML。

Angular 端提交支付 token

旧版 Square SDK 通常将前端生成的值称为 nonce,新版 Web Payments SDK 通常称为 payment token。名称不同,但它们都只是支付来源凭证,并不是 Square Access Token。

前端无需再提交隐藏表单,直接调用 Firebase Callable Function:

import { Injectable } from '@angular/core';
import { Functions, httpsCallable } from '@angular/fire/functions';

interface CreatePaymentRequest {
  sourceId: string;
  orderId: string;
  checkoutAttemptId: string;
}

interface CreatePaymentResponse {
  paymentId: string;
  status: string;
}

@Injectable({ providedIn: 'root' })
export class PaymentService {
  constructor(private functions: Functions) {}

  async createPayment(
    sourceId: string,
    orderId: string
  ): Promise<CreatePaymentResponse> {
    const createSquarePayment = httpsCallable<
      CreatePaymentRequest,
      CreatePaymentResponse
    >(this.functions, 'createSquarePayment');

    const result = await createSquarePayment({
      sourceId,
      orderId,
      checkoutAttemptId: crypto.randomUUID()
    });

    return result.data;
  }
}

在现有回调中,可以用下面的调用替换表单提交:

cardNonceResponseReceived: async function (
  errors: any[],
  nonce: string,
  cardData: unknown
) {
  if (errors) {
    errors.forEach(error => console.error(error.message));
    return;
  }

  try {
    const result = await this.paymentService.createPayment(
      nonce,
      this.orderId
    );

    console.log('Payment result:', result);
  } catch (error) {
    console.error('Payment failed:', error);
  }
}

实际使用时,需要确认回调中的 this 指向 Angular 组件。使用箭头函数会更稳妥,也可以在初始化 Square SDK 时显式绑定组件实例。

如果使用新版 Web Payments SDK,通常先调用卡组件的 tokenize():

async pay(): Promise<void> {
  const tokenResult = await this.card.tokenize();

  if (tokenResult.status !== 'OK' || !tokenResult.token) {
    console.error(tokenResult.errors);
    return;
  }

  const result = await this.paymentService.createPayment(
    tokenResult.token,
    this.orderId
  );

  console.log(result);
}

Firebase Cloud Function 创建支付

下面使用 Firebase Functions v2 和 Square REST API 展示主要流程。示例基于以下前提:

  • 用户已经通过 Firebase Authentication 登录。
  • Firestore 中存在 orders/{orderId}。
  • 订单包含可信的 ownerUid、amount、currency 和 status。
  • Cloud Functions 运行时支持原生 fetch。
  • Square API 版本由部署配置固定,示例中的占位符不能直接用于生产环境。
import { initializeApp } from 'firebase-admin/app';
import { getFirestore, FieldValue } from 'firebase-admin/firestore';
import { defineSecret, defineString } from 'firebase-functions/params';
import { HttpsError, onCall } from 'firebase-functions/v2/https';

initializeApp();

const db = getFirestore();

const squareAccessToken = defineSecret('SQUARE_ACCESS_TOKEN');
const squareLocationId = defineString('SQUARE_LOCATION_ID');
const squareApiVersion = defineString('SQUARE_API_VERSION');
const squareEnvironment = defineString('SQUARE_ENVIRONMENT');

export const createSquarePayment = onCall(
  {
    secrets: [squareAccessToken],
    enforceAppCheck: true
  },
  async request => {
    if (!request.auth) {
      throw new HttpsError(
        'unauthenticated',
        'You must sign in before making a payment.'
      );
    }

    const sourceId = request.data?.sourceId;
    const orderId = request.data?.orderId;
    const checkoutAttemptId = request.data?.checkoutAttemptId;

    if (
      typeof sourceId !== 'string' ||
      typeof orderId !== 'string' ||
      typeof checkoutAttemptId !== 'string'
    ) {
      throw new HttpsError(
        'invalid-argument',
        'Invalid payment request.'
      );
    }

    const orderRef = db.collection('orders').doc(orderId);
    const orderSnapshot = await orderRef.get();

    if (!orderSnapshot.exists) {
      throw new HttpsError('not-found', 'Order does not exist.');
    }

    const order = orderSnapshot.data()!;

    if (order.ownerUid !== request.auth.uid) {
      throw new HttpsError(
        'permission-denied',
        'You cannot pay for this order.'
      );
    }

    if (order.status === 'paid') {
      return {
        paymentId: order.squarePaymentId,
        status: 'COMPLETED'
      };
    }

    if (
      !Number.isInteger(order.amount) ||
      order.amount <= 0 ||
      typeof order.currency !== 'string'
    ) {
      throw new HttpsError(
        'failed-precondition',
        'Order amount is invalid.'
      );
    }

    const baseUrl =
      squareEnvironment.value() === 'production'
        ? 'https://connect.squareup.com'
        : 'https://connect.squareupsandbox.com';

    const squareResponse = await fetch(`${baseUrl}/v2/payments`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${squareAccessToken.value()}`,
        'Content-Type': 'application/json',
        'Square-Version': squareApiVersion.value()
      },
      body: JSON.stringify({
        source_id: sourceId,
        idempotency_key: checkoutAttemptId,
        amount_money: {
          amount: order.amount,
          currency: order.currency
        },
        location_id: squareLocationId.value(),
        reference_id: orderId,
        autocomplete: true
      })
    });

    const squareResult = await squareResponse.json();

    if (!squareResponse.ok || !squareResult.payment) {
      console.error('Square payment error', {
        orderId,
        errors: squareResult.errors
      });

      throw new HttpsError(
        'failed-precondition',
        'The payment could not be completed.'
      );
    }

    const payment = squareResult.payment;

    await orderRef.update({
      status:
        payment.status === 'COMPLETED'
          ? 'paid'
          : 'payment_processing',
      squarePaymentId: payment.id,
      squarePaymentStatus: payment.status,
      paidAt:
        payment.status === 'COMPLETED'
          ? FieldValue.serverTimestamp()
          : null,
      updatedAt: FieldValue.serverTimestamp()
    });

    return {
      paymentId: payment.id,
      status: payment.status
    };
  }
);

Square SDK 的包结构和方法签名可能随版本变化,所以示例直接调用 /v2/payments,以免混用不同版本的 SDK。生产环境应在配置中固定已经验证的 Square-Version,不要随意填写未经确认的版本号。

配置密钥

通过 Firebase Secret Manager 配置 Access Token:

firebase functions:secrets:set SQUARE_ACCESS_TOKEN

非敏感配置可以按照项目采用的 Firebase Functions 配置方式提供,例如:

SQUARE_LOCATION_ID
SQUARE_API_VERSION
SQUARE_ENVIRONMENT

可以约定 SQUARE_ENVIRONMENT 的值为 sandbox 或 production。沙箱 token 必须配合沙箱 API 地址使用,生产 token 则必须配合生产 API 地址。

部署函数时可以执行:

firebase deploy --only functions:createSquarePayment

为什么金额不能从 Angular 直接传入

下面的设计并不安全:

createPayment({
  sourceId: nonce,
  amount: 100
});

用户可以修改浏览器发出的请求。如果后端直接采用请求中的 amount,攻击者可能把订单金额从 10000 改成 1。

前端只应提交订单 ID。Cloud Function 收到请求后,必须根据订单 ID 从 Firestore、商品表或服务端订单记录中重新计算金额:

const order = await loadOrder(orderId);
const amount = calculateTrustedAmount(order);

Square 的 amount_money.amount 通常使用货币最小单位的整数。例如,美元使用分,而不是浮点数:

amount_money: {
  amount: 1999,
  currency: 'USD'
}

这段配置表示 19.99 USD。某种货币是否有特殊的最小单位规则,需要结合订单货币和 Square 账户所在市场处理。

必须处理幂等性

网络超时、用户重复点击或客户端自动重试,都可能导致同一笔付款请求被发送多次。idempotency_key 可以防止同一次支付尝试造成重复扣款。

前端应为一次结账尝试生成一个稳定的 UUID:

const checkoutAttemptId = crypto.randomUUID();

重试同一次请求时,必须继续使用原来的值,不能在每次重试时重新生成。后端还应保存该值,并将它与 orderId、uid 绑定,防止同一个标识被用于不同订单。

较完整的实现可以创建:

paymentAttempts/{checkoutAttemptId}

其中记录:

{
  uid,
  orderId,
  status,
  squarePaymentId,
  createdAt
}

Square 的幂等性机制用于避免重复创建付款,Firestore 中的记录用于管理业务状态和查询支付结果。

安全注意事项

  • 不要在 Angular 中保存 Square Access Token,包括 environment.prod.ts。Angular 环境配置最终会被打包进浏览器 bundle,无法保密。
  • 不要将 Access Token 写入 Firestore。即使已经配置 Security Rules,也没有必要承担这种风险。
  • 不要长期保存 nonce/payment token。获取后应立即发送给 Cloud Function;如果请求失败,应按照 Square SDK 的流程重新生成。
  • 必须校验 Firebase Authentication 身份、订单所有者、订单状态、金额和货币。
  • 建议启用 Firebase App Check。设置 enforceAppCheck: true 之前,应先完成 Angular 客户端配置,否则合法请求也会遭到拒绝。
  • Firestore Security Rules 无法替代 Cloud Function 中的业务校验。客户端不应有权限直接把订单状态改成 paid。
  • 不要把 Square 返回的完整错误对象直接发给浏览器。服务端可以记录诊断信息,客户端只接收适合展示给用户的提示。
  • 前端显示”付款成功”之前,应检查 Square 返回的实际支付状态。如果状态不是 COMPLETED,需要继续查询,或通过 Square Webhook 更新最终结果。
  • Webhook 必须验证 Square 提供的签名,不能只根据请求正文修改订单状态。
  • 对于金额较高或库存敏感的订单,最好先锁定订单状态,再调用 Square,并结合幂等键和支付尝试记录处理并发请求。

所以,“serverless”并不等于完全没有后端代码,而是由 Firebase Cloud Functions 托管可信后端。Angular 负责安全采集付款信息并生成 token,真正创建付款的操作必须在 Cloud Function 中完成。

备注:内容仅供参考。