mcpField
P
payment-ppp-pos

payment-ppp-pos

github.com/vtex开发47188 次安装2 周前更新
代码仓库

关于 payment-ppp-pos

payment-ppp-pos 是用于创建、扩展或调试 VTEX Sales App 实体 POS 终端支付连接器的技能。它聚焦 POS 专属的 PPP 状态流程及 VTEX IO / PPF 约束,而非通用电商支付连接器。

主要功能

  1. 01实现 POS 支付流程端点
  2. 02指导终端识别与结果轮询
  3. 03校验 POS 支付方式名称
  4. 04区分 POS 与电商银行卡处理
  5. 05生成连接器代码和测试清单

适用场景

  1. 01创建 VTEX IO POS 连接器
  2. 02为现有 PPP 中间件添加 POS 支持
  3. 03排查终端识别和支付轮询问题
  4. 04准备 POS 连接器验证测试

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/vtex/skills --skill payment-ppp-pos

安装后在 Claude Code 中输入/payment-ppp-pos确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
examples.md
generation.md
ppf-scaffold.md

详细介绍

PPP 应用于 POS(VTEX Sales App)

适用场景

当任务是创建、扩展或调试支付连接器,以便通过 VTEX Sales App 在 POS 终端上刷实体卡收费时,使用此技能。

  • 用户提到 PPP + POS、POS 连接器、店内支付、Sales App 刷卡支付、支付终端,或 Venda Direta Credito / Venda Direta Debito
  • 权威来源是 Payment Provider Protocol for Point of Sale (POS)
  • 目标是消除“从零开始实现”的障碍:从 Payment Provider Framework 入门指南克隆官方 PPF 示例,或在现有 PPP 上叠加 POS 支持,而不是生成通用电商连接器

在需要时加载配套文档。首次回复时不要一次性加载全部三份。

配套文档加载时机
generation.md用户要求创建 POS 连接器时。先询问四个问题,然后按列出的顺序实现。
ppf-scaffold.md托管环境为 VTEX IO / PPF 时。将覆盖文件复制到官方 PPF 示例应用上。
examples.md需要预期 JSON、状态机表格,或 IO 重试与独立通知的相关内容时。

以下硬性约束优先于电商 PPP 和 PCI 惯例。如果生成的文件违反其中任何一条,请停止并先修正,再继续编写文件。

以下情况不要默认使用此技能:

决策规则

  • 除非合作伙伴已经托管 PPP 中间件,否则优先选择 VTEX IO / PPF。从 Payment Provider Framework 指南中克隆官方示例应用,并叠加 POS 支持;不要从空的 Express 应用开始。
  • POS 支付仍属于 PPP。先实现六个支付流程端点,再叠加此技能中的 POS 规则。
  • authorize() 是一个状态机。它不会等待 POS,只返回当前阶段。连接器会响应 Create Payment;它不会推动 Gateway 进入下一阶段。终端应用关闭后,VTEX Sales App 会触发 Gateway 回调,由 Gateway 发出 Create Payment(POS 指南第 8 步)。随后,Wait for confirmation 会轮询 Gateway,由 Gateway 发出更多 Create Payment(第 9 步)。在处理器报告最终状态之前,不要向 callbackUrl 发送 POST——在待处理状态下重试没有必要,每次 authorize 还会额外增加约 1 秒。 | 已存储阶段 | 后续由谁发起 Create Payment | Create Payment 返回内容 | |---|---|---| | (尚无) | Sales App 结账 → Gateway(步骤 3) | 保存 identify-terminal;undefined + vtex.terminal-connector-app | | identify-terminal(无序列号) | Gateway / Sales App | 同一个终端应用,直到 submitUrl 保存序列号 | | identify-terminal(已保存序列号) | 终端应用关闭后由 Sales App 发起(步骤 8) | 仅启动一次处理器扣款;undefined + vtex.challenge-wait-for-confirmation | | await-pos | 等待确认轮询(步骤 9) | 同一个等待应用;不进行第二次扣款;不重试 POST | | approved / denied | IO 重试后由等待应用 / Sales App 发起 | 最终状态 + cardBrand + firstDigits + lastDigits |
  • Venda Direta Credito 和 Venda Direta Debito 是异步面对面刷卡支付方式。它们会返回 status: "undefined",之后通过 callbackUrl 完成处理。不要将其视为 Visa/Mastercard 同步授权。
  • 根据 paymentMethod 分支处理。在 PPF 中,使用 @vtex/payment-provider 中的 isDirectSaleAuthorization(规范名称为 DirectSale,且缩窄后的类型不含 card / secureProxyUrl)。在独立 HTTP 服务中,比较同样的 ASCII 字符串。连接器可以同时支持电商和 POS。
  • POS 流程中,Gateway 不会收到购物者的银行卡信息。所有 card 字段都会是 null。此流程中不要调用 Secure Proxy。
  • 除非合作伙伴有明确记录的理由要构建自定义应用,否则请使用 VTEX 提供的现成支付应用:
    • 识别终端:vtex.terminal-connector-app
    • 等待 POS 结果:vtex.challenge-wait-for-confirmation
  • 不要使用 vtex.challenge-terminal-connector-app。该名称在 POS 指南中仅作为流程图示例出现;实现规范和已发布的应用名称是 vtex.terminal-connector-app。
  • paymentAppData.payload 是序列化的 JSON 字符串,而不是嵌套对象。在 PPF 中,使用 Authorizations.pending 构建待处理响应,这样类型错误会在编译时报出。不要用 as AuthorizationResponse 断言这些对象,因为这种转换会掩盖错误。POS 指南要求的 cardBrand、firstDigits 和 lastDigits 不在 SDK 响应类型中(已在 @vtex/payment-provider 1.4.0 和 1.8.0 中检查);只有最终的已批准/已拒绝响应才有理由使用类型断言。
  • 支付处理器与实体 POS 之间的通信不属于 VTEX。连接器只负责识别终端、启动处理器交易,并向 Gateway 报告 approved 或 denied。
  • 不使用 VTEX IO 时,callbackUrl 是通知端点:POST 最终状态(包含 POS 银行卡字段)。使用 VTEX IO 时,callbackUrl 是重试端点:先持久化状态,然后使用空请求体进行 POST。在 PaymentProvider 方法中使用 this.retry(request)。从 service.json 的额外路由中使用 new Payments(ctx.vtex).retry(callbackUrl)——它与 this.retry 使用同一个客户端(gateway-retry 指标、IO 跟踪、已配置的超时)。在 IO 中不要 POST status / cardBrand。在下一次 Create Payment / authorize() 时返回最终状态和 POS 银行卡字段。
  • POS 序列号收集和处理器 webhook 是额外的公共路由,不是 PPP 入站请求。官方示例中的 this.callback(req, resp) 仅用于 Test Suite。
  • delayToCancel 必须大于或等于 secondsWaiting。
  • 生产环境中,每个连接器端点都必须在 20 秒内响应(Payment Provider Test Suite 运行期间必须在 5 秒内响应)。
  • 此技能可以生成连接器代码和测试清单,但不能替代真实 POS、Sales App 设备、已配置的测试账户或认证。

硬性约束

代码示例使用了示例标识符(PartnerPos、partnername、connector-partnerpos、provider.example.com)和示例数字(SECONDS_WAITING = 600、firstDigits / lastDigits,例如 411111 / 1111)。请将它们替换为合作伙伴的连接器名称、公共主机、超时时间和处理方卡号片段。600 不是 VTEX 要求的默认值;规则是 delayToCancel >= secondsWaiting。示例 BIN / 卡号后四位的值是截断后的片段,不是 PAN。

约束:清单必须按精确的 API 名称声明 POS 支付方式

GET /manifest(以及 PPF paymentProvider/configuration.json 中的 paymentMethods)必须包含 Venda Direta Credito 和/或 Venda Direta Debito,且名称必须与 POS 指南中的 ASCII 名称完全一致。带重音符号的 Admin 标签(Venda Direta Crédito、Venda Direta Débito)仅用作显示名称。

原因

Gateway 和 Sales App 的支付条件会绑定到这些支付方式名称。如果清单仅列出 Visa / Mastercard,商家就无法配置 POS。如果连接器自行编造名称,例如 "POS Credit",Admin 和 Sales App 就不会关联店内支付流程。

检测

如果任务涉及 POS,且 paymentMethods 中缺少 Venda Direta Credito 或 Venda Direta Debito,请停止并先添加它们,再生成其他代码。

正确

{
  "paymentMethods": [
    { "name": "Venda Direta Debito", "allowsSplit": "onCapture" },
    { "name": "Venda Direta Credito", "allowsSplit": "onCapture" }
  ]
}

错误

{
  "paymentMethods": [
    { "name": "Visa", "allowsSplit": "onCapture" },
    { "name": "Mastercard", "allowsSplit": "onCapture" },
    { "name": "POS Credit", "allowsSplit": "onCapture" }
  ]
}
约束:根据 paymentMethod 检测 POS,且绝不从 Gateway 读取银行卡 PAN

对于 Venda Direta Credito 和 Venda Direta Debito,Create Payment 会将 card.holder、card.number、card.csc、card.expiration、card.document 和 card.token 设为 null。连接器不得要求这些字段,不得调用 Secure Proxy,也不得因缺少银行卡数据而拒绝支付。

在 PPF 上,使用 @vtex/payment-provider 中的 isDirectSaleAuthorization 进行分支判断。该守卫使用 SDK 的 DirectSale 名称(精确的 ASCII 名称),而 DirectSaleAuthorization 没有 card,也没有 secureProxyUrl,因此缩窄类型后,访问 authorization.card 会产生编译错误。不要自行编写 POS_METHODS,也不要使用 (authorization as any).paymentMethod。在独立 HTTP 实现中,将 paymentMethod 与相同的两个字符串进行比较。callbackUrl 和 value 已经在 Authorization 上;不要进行类型转换。

原因

购物者在实体终端上付款。将 POS 当作电商银行卡授权处理,会导致扣款失败;更糟的是,还可能使用空令牌调用 Secure Proxy。手动编写带重音符号的字符串,会导致问题在 Gateway 而不是编译时暴露。

检测

如果 POS 授权逻辑读取 card.number / secureProxyUrl,或者在 card 为 null 时返回 denied,请停止并将 POS 逻辑与电商逻辑分开。如果 PPF 代码使用 as any 读取 paymentMethod、callbackUrl 或 value,请停止并改用类型化字段/守卫。

正确

import {
  isDirectSaleAuthorization,
  AuthorizationRequest,
} from "@vtex/payment-provider";

async function authorize(request: AuthorizationRequest) {
  if (isDirectSaleAuthorization(request)) {
    return authorizePos(request);
  }

  return authorizeEcommerce(request);
}

错误

async function authorize(request: CreatePaymentRequest) {
  if (!request.card?.number) {
    return { status: "denied", message: "Missing card number" };
  }

  return secureProxy.charge(request.card, request.secureProxyUrl);
}
约束:首次 Create Payment 响应必须打开终端 Payment App

当连接器尚未收到此 paymentId 对应的 POS 序列号时,Create Payment MUST 返回 status: "undefined" 和 vtex.terminal-connector-app 的 paymentAppData。payload MUST 是一个 JSON 字符串,其中包含 submitUrl,即接收 {"serialNumber": "12345"} 的 HTTPS 端点。

将示例中的主机替换为合作伙伴实际的公网 URL。响应前持久化 callbackUrl 和 paymentId。

在 PPF 中,返回 Authorizations.pending(authorization, { delayToCancel, paymentAppData })。AppData.payload 的类型为 Maybe<string>,因此对象类型的 payload 会导致编译错误。不要将该对象包装在 as AuthorizationResponse 中。

为何重要 没有此响应,Sales App 就不会打开摄像头/条码挑战,因此处理器无法获知要使用哪个终端。嵌套的 payload 对象不会注入 Payment App。对整个响应进行类型转换会掩盖此编译错误。

检测 如果首次 POS 响应为 approved/denied、未包含 paymentAppData、使用 vtex.challenge-terminal-connector-app、将 payload 设为对象,或在 pending 响应体上使用 as AuthorizationResponse,请立即停止并修复。

正确

import { Authorizations } from "@vtex/payment-provider";

const SECONDS_WAITING = 600;

async function authorizePos(request: CreatePaymentRequest) {
  const existing = await store.get(request.paymentId);

  if (!existing) {
    await store.save(request.paymentId, {
      phase: "identify-terminal",
      status: "undefined",
      callbackUrl: request.callbackUrl,
      paymentMethod: request.paymentMethod,
      value: request.value,
    });

    return Authorizations.pending(request, {
      acquirer: "PartnerPos",
      code: "POS_IDENTIFY_TERMINAL",
      message: "Identify the POS terminal",
      delayToAutoSettle: 21600,
      delayToAutoSettleAfterAntifraud: 1800,
      delayToCancel: SECONDS_WAITING,
      paymentAppData: {
        appName: "vtex.terminal-connector-app",
        payload: JSON.stringify({
          submitUrl: `https://provider.example.com/pos/serial/${request.paymentId}`,
        }),
      },
    });
  }

  return continuePos(request, existing);
}

错误

return {
  paymentId: request.paymentId,
  status: "approved",
  paymentAppData: {
    appName: "vtex.challenge-terminal-connector-app",
    payload: {
      submitUrl: "https://provider.example.com/pos/serial",
    },
  },
};
约束:收到序列号后,使用 vtex.challenge-wait-for-confirmation 等待

当 submitUrl 收到 {"serialNumber": "..."} 时,连接器 MUST 将该序列号与 paymentId 关联(POS 指南步骤 7)。不要在该处理器中启动 POS 扣款。

Sales App 在终端应用关闭后触发的下一次 Create Payment(步骤 8)MUST 启动一次处理器扣款,然后返回 status: "undefined",以及带有 payload {"secondsWaiting": <int>} 的 vtex.challenge-wait-for-confirmation(步骤 8.b–8.d)。后续轮询(步骤 9)MUST NOT 启动另一次扣款,也 MUST NOT POST callbackUrl。

序列号尚不存在时不要启动 POS 扣款,也不要在首次 authorize() 时启动(此时还没有序列号)。

secondsWaiting 是 Wait for confirmation 应用轮询的时长。delayToCancel(Create Payment 响应中的秒数)是 Gateway 在调用 Cancel 前让支付保持 undefined 的时长。设置 delayToCancel >= secondsWaiting。

为何重要 在终端尚未识别时启动扣款,会向错误的设备扣款,或根本无法扣款。在 submitUrl 中启动扣款会偏离已发布的流程(8.b 启动支付,8.c 确认支付已启动,8.d 返回等待应用)。如果 delayToCancel 短于 secondsWaiting,Gateway 会在 Sales App 仍在等待时取消支付。如果 Create Payment 在每次轮询时都启动新的处理器扣款,购物者就会被扣款两次。

检测 如果尚未保存序列号就返回 Wait for confirmation、startPosCharge 在序列号处理器中或每次轮询时运行、缺少 secondsWaiting,或 delayToCancel < secondsWaiting,请立即停止。

正确

async function onSerialNumber(paymentId: string, serialNumber: string) {
  const payment = await store.get(paymentId);
  if (!payment || payment.phase !== "identify-terminal") {
    return;
  }

  await store.save(paymentId, {
    ...payment,
    serialNumber,
  });
}

function waitingResponse(request: CreatePaymentRequest) {
  return Authorizations.pending(request, {
    acquirer: "PartnerPos",
    code: "POS_WAITING",
    message: "Waiting for POS confirmation",
    delayToAutoSettle: 21600,
    delayToAutoSettleAfterAntifraud: 1800,
    delayToCancel: SECONDS_WAITING,
    paymentAppData: {
      appName: "vtex.challenge-wait-for-confirmation",
      payload: JSON.stringify({ secondsWaiting: SECONDS_WAITING }),
    },
  });
}

错误

async function authorizePos(request: CreatePaymentRequest) {
  await processor.startPosCharge({ paymentId: request.paymentId });

  return {
    paymentId: request.paymentId,
    status: "undefined",
    delayToCancel: 30,
    paymentAppData: {
      appName: "vtex.challenge-wait-for-confirmation",
      payload: JSON.stringify({ secondsWaiting: 600 }),
    },
  };
}
约束:只能通过必需的回调批准或拒绝,包括 POS 卡片字段

处理器读取实体卡片,而不是 VTEX。处理器报告最终结果时,连接器必须通知 Gateway。没有此回调,POS 交易无法批准或拒绝。请包含:

  • status:"approved" 或 "denied"
  • cardBrand
  • firstDigits(前六位数字 / BIN)
  • lastDigits(后四位数字)

这三个卡片字段是商户对账所必需的。它们由终端读取卡片后从处理器获取,而不是来自原始 Create Payment card 对象。

不要记录或存储完整 PAN。firstDigits + lastDigits 是允许保留的片段。

为什么这很重要 如果不通知 Gateway,Sales App 会一直停留在 Wait for confirmation,直到超时后取消。即使终端上的扣款成功,缺少 BIN / 后四位也会导致财务报告出错。在 IO 上,向 callbackUrl 发送独立的通知正文并不能完成流程——Gateway 需要先收到无正文的重试 POST,然后再调用一次新的 authorize()。

检测 如果 POS 代码在首次 Create Payment 时返回 approved,请停止。如果 PPF 代码在 fetch 中向 callbackUrl 发送包含 status / cardBrand 的正文,请停止并改用 Payments.retry / this.retry。如果 Service 路由调用 this.retry(这里没有 this),请停止并改用 new Payments(ctx.vtex).retry(stored.callbackUrl)。如果待处理的 authorize() POST 请求向 callbackUrl 发送内容以“提示”Gateway,请停止。如果独立代码在通知 POST 中遗漏了这三个卡片字段,请停止。

正确(PPF 额外路由——典型的处理器 webhook)

import { Payments } from "@vtex/payment-provider";

await vbase.saveJSON("pos", paymentId, {
  status: "approved",
  cardBrand: event.cardBrand,
  firstDigits: event.firstDigits,
  lastDigits: event.lastDigits,
  authorizationId: event.authorizationId,
  nsu: event.nsu,
  tid: event.tid,
});

await new Payments(ctx.vtex).retry(callbackUrl);

正确(仅在 PaymentProvider 方法内部)

return this.retry(authorization);

下一次 authorize() 必须从持久化记录中返回 status、cardBrand、firstDigits 和 lastDigits。有关独立通知负载,请参阅 examples.md。

错误

async function authorizePos(request: CreatePaymentRequest) {
  return {
    paymentId: request.paymentId,
    status: "approved",
    message: "Assume the terminal will succeed",
  };
}

// Also wrong on VTEX IO: notification payload to callbackUrl
await fetch(authorization.callbackUrl, {
  method: "POST",
  body: JSON.stringify({
    paymentId: request.paymentId,
    status: "approved",
    cardBrand: "Visa",
    firstDigits: "411111",
    lastDigits: "1111",
  }),
});
约束:保持 Create Payment 幂等,并确保响应在 20 秒内返回

对于给定的 paymentId,Create Payment 必须返回当前存储的阶段和状态。不得创建第二笔处理器交易。只要状态仍为 undefined,Wait for confirmation 就会多次轮询 Create Payment。这些调用可能会重叠(requestId 不同,paymentId 相同)——先读取再保存的顺序操作可能会创建两条记录或丢弃阶段更新。

在 PPF 上,使用 getRawJSON 持久化(捕获 etag),然后使用 saveJSON(..., ifMatch)。遇到 412 时,重新读取并根据存储的文档继续操作。请结合 payment-idempotency 使用。首次写入 identify-terminal 时,不要写入每个请求各不相同的字段(随机 tid、requestId)。

VBase 会将元数据存储桶组成为 {vendor}.{appName}.{bucket},并拒绝超过 50 个字符的名称(400 Metadata bucket name too large)。请使用较短的存储桶名称,例如 pos。技能占位符(partnername.connector-partnerpos)适用于 pos-payments;实际的 vendor.appName 更长时则不适用。

不要在 HTTP 处理器中等待顾客完成 POS 操作。使用 compare-and-set 将 identify-terminal 变为 await-pos,仅在该写入获胜时启动处理器扣款,然后返回。密码重试和余额不足处理应在处理器与 POS 之间完成。

为什么这很重要 Gateway 会将超过 20 秒的响应视为失败。每次轮询都再次调用收单机构时,就会发生重复 POS 扣款。同一 paymentId 的首次写入发生重叠时,会返回不一致的 tid,破坏 PPP 幂等性。过长的 VBase 存储桶会在首次 Create Payment 时失败,并返回含糊的 400 错误。

检测 如果处理器 await 终端结果、在 phase 已是 await-pos 或最终状态时调用 processor.startPosCharge、使用不带 etag 的 getJSON + saveJSON,或者使用的 VBase 存储桶会导致 {vendor}.{appName}.{bucket} 超过 50 个字符,请停止。

正确

const POS_BUCKET = "pos";

async function savePos(vbase: VBase, paymentId: string, record: PosRecord, etag?: string) {
  try {
    await vbase.saveJSON(POS_BUCKET, paymentId, record, undefined, etag);
    return true;
  } catch (err) {
    const status = (err as any).response && (err as any).response.status;
    if (status === 412) {
      return false;
    }
    throw err;
  }
}

async function continuePos(
  vbase: VBase,
  request: CreatePaymentRequest,
  existing: PosRecord,
  etag?: string
) {
  if (existing.status === "denied") {
    return {
      ...Authorizations.deny(request, {
        acquirer: "PartnerPos",
        code: existing.code,
        message: existing.message,
        tid: existing.tid,
        delayToCancel: SECONDS_WAITING,
      }),
      cardBrand: existing.cardBrand,
      firstDigits: existing.firstDigits,
      lastDigits: existing.lastDigits,
    } as AuthorizationResponse;
  }

  if (existing.status === "approved") {
    return {
      ...Authorizations.approve(request, {
        authorizationId: existing.authorizationId as string,
        nsu: existing.nsu,
        tid: existing.tid as string,
        acquirer: "PartnerPos",
        code: existing.code,
        message: existing.message,
      }),
      delayToCancel: SECONDS_WAITING,
      cardBrand: existing.cardBrand,
      firstDigits: existing.firstDigits,
      lastDigits: existing.lastDigits,
    } as AuthorizationResponse;
  }

  if (existing.phase === "await-pos") {
    return waitingResponse(request);
  }

  if (existing.serialNumber && existing.phase === "identify-terminal") {
    const won = await savePos(
      vbase,
      request.paymentId,
      { ...existing, phase: "await-pos" },
      etag
    );
    if (won) {
      await processor.startPosCharge({
        paymentId: request.paymentId,
        serialNumber: existing.serialNumber,
        amount: existing.value,
      });
    }
    return waitingResponse(request);
  }

  return Authorizations.pending(request, {
    acquirer: "PartnerPos",
    code: "POS_IDENTIFY_TERMINAL",
    message: "Identify the POS terminal",
    delayToCancel: SECONDS_WAITING,
    paymentAppData: {
      appName: "vtex.terminal-connector-app",
      payload: JSON.stringify({
        submitUrl: `https://provider.example.com/pos/serial/${request.paymentId}`,
      }),
    },
  });
}

错误

async function authorizePos(request: CreatePaymentRequest) {
  const result = await processor.startPosCharge(request);
  await processor.waitUntilCardFinished(result, { timeoutMs: 120000 });
  return { paymentId: request.paymentId, status: result.status };
}
约束:通过公共自定义路由收集 POS 序列号,而不是使用 inbound-request

vtex.terminal-connector-app 会向 submitUrl POST {"serialNumber":"..."}。该 URL 必须是 Payment App 可调用的公共 HTTPS 端点。在 PPF 上,在 service.json 的 routes 中声明该路由,并将其传入 PaymentProviderService。此步骤不要使用 POST /payments/{paymentId}/inbound-request/{action}。

为何这很重要 Inbound-request 由 Gateway 发起。终端应用会从 Sales App 设备发起 POST。如果 submitUrl 是 inbound 路径,序列号就不会送达,POS 扣款也不会启动。

检测 如果 submitUrl 包含 /inbound,请停止。如果 PPF 没有用于收集序列号的额外 service.json 路由,请停止。

正确

{
  "routes": {
    "posSerial": {
      "path": "/_v/partnerpos/pos/serial/:paymentId",
      "public": true
    }
  }
}

错误

{
  "submitUrl": "https://account.myvtex.com/_v/api/my-connector/payments/PAY1/inbound/serial"
}

推荐模式

按顺序遵循 generation.md。对于 PPF,请使用 ppf-scaffold.md,在 Payment Provider Framework 指南中的官方示例应用上叠加 POS。请在 examples.md 中比较 IO 与独立版的负载。

Sales App checkout
  → Gateway POST /payments (Venda Direta Credito|Debito, card = null)
  → authorize(): undefined + vtex.terminal-connector-app (submitUrl)
  → POST submitUrl {"serialNumber":"..."}   (public custom route; store serial only)
  → Sales App closes the terminal app and calls the Gateway (connector does not retry)
  → authorize(): start processor charge once, then undefined + vtex.challenge-wait-for-confirmation
  → Wait for confirmation polls authorize() (still undefined; no second charge; no retry POST)
  → Processor webhook
       PPF: persist + Payments.retry(callbackUrl) / this.retry inside PaymentProvider
       Standalone: POST notification payload (status + card fragments)
  → authorize() returns approved|denied + cardBrand/firstDigits/lastDigits
  → Sales App places the order

常见故障模式

  • 用户要求 PPP + POS,却生成了电商 PPP 连接器(Visa/Mastercard + Secure Proxy)。
  • 清单中遗漏 Venda Direta Credito / Venda Direta Debito,或将带重音符号的 Admin 标签用作 API 名称。
  • 在终端完成操作前就返回 approved,或在 Create Payment 中等待插入银行卡。
  • 使用 vtex.challenge-terminal-connector-app,而不是 vtex.terminal-connector-app。
  • 将 paymentAppData.payload 作为对象发送,而不是使用 JSON.stringify(...);或者在待处理的请求体上使用 as AuthorizationResponse 来掩盖这一问题。
  • 将 delayToCancel 设置得低于 secondsWaiting,导致 Gateway 在 Wait 应用仍在轮询时取消付款。
  • 在 submitUrl 处理程序中启动 POS 扣款(POS 指南第 8.b 步是在随后的 Create Payment 中启动扣款)。
  • 每次 Create Payment 轮询时都启动新的 POS 扣款(幂等性失效)。
  • 在待处理响应中 POST callbackUrl 来“提醒”Gateway。Sales App 和 Wait for confirmation 已会驱动 Create Payment。
  • 对同一个 paymentId 的并发 Create Payment 调用使用 getJSON + saveJSON,却不使用 etag。
  • 使用的 VBase bucket 会导致 {vendor}.{appName}.{bucket} 超过 50 个字符(默认使用 pos)。
  • 跳过 Gateway 回调,或遗漏 cardBrand、firstDigits 和 lastDigits。
  • **在 VTEX IO 上:**将独立通知负载(status + 卡片字段)POST 到 callbackUrl,而不是发送无请求体的 Payments.retry / this.retry。
  • 将示例中的 Test Suite this.callback(req, resp) 复制为 POS 处理器 webhook。
  • 从 service.json 额外路由调用 this.retry(那里没有 PaymentProvider 的 this)。在那里使用 new Payments(ctx.vtex).retry(callbackUrl)。
  • 将 submitUrl 放在 PPP inbound-request 上,而不是公共自定义路由上。
  • 因为“这是银行卡付款”,就对 POS authorize 应用 payment-pci-security Secure Proxy。
  • 构建 Sales App 购物车/PDP 扩展,而不是支付连接器。
  • 合作伙伴可以使用 PPF / 官方示例应用时,却从空 Express 应用开始构建。

审查清单

  • 对于 PPF,是否基于官方 PPF 示例(或现有 PPF 连接器)叠加构建应用,而不是从空白 HTTP 服务开始?
  • /manifest(以及 IO 的 configuration.json)是否声明了 Venda Direta Credito 和/或 Venda Direta Debito,且名称完全一致?
  • authorize 是否通过 isDirectSaleAuthorization(PPF)或完全匹配的 ASCII 方法名(独立实现)进行分支,并在 POS 场景跳过 Gateway 卡号 PAN / Secure Proxy?
  • POS 的首个响应是否为 Authorizations.pending + vtex.terminal-connector-app,且 payload.submitUrl 为字符串(未将整个响应强制转换)?
  • submitUrl 是否为一个公共自定义路由(service.json),能够接收 {"serialNumber": "..."},且不是 inbound-request?
  • submitUrl 是否只持久化序列号(步骤 7),并由后续的 Create Payment 启动一次处理器扣款(步骤 8.b)?
  • 等待响应是否为 undefined + vtex.challenge-wait-for-confirmation,并包含 secondsWaiting?
  • delayToCancel >= secondsWaiting 是否成立,且每个端点的响应时间是否都少于 20 秒?
  • 同一 paymentId 的并发 Create Payment 调用是否安全(getRawJSON + ifMatch,不产生第二笔 POS 扣款,不在 pending 时重试)?
  • PPF VBase 存储桶名称是否足够短,使 {vendor}.{appName}.{bucket} 不超过 50 个字符(使用 pos)?
  • PPF: 处理器 webhook 是否先持久化卡片片段,再调用 Payments.retry(callbackUrl)(或在 PaymentProvider 内调用 this.retry)?下一次 authorize() 是否返回卡片片段(仅对最终响应进行强制转换)?
  • 独立实现: 通知 POST 是否包含 status、cardBrand、firstDigits、lastDigits?
  • 是否已实现 Cancel,以便 Gateway 超时可以中止状态为 undefined 的 POS 付款?
  • 是否已告知合作伙伴仍需要 Sales App 设备、POS 终端、测试账户、付款条件和认证?
测试前提条件(无法通过技能自动化)

VTEX 通常会提供预配置的商店用于连接器测试。如需手动配置商店:

  1. 安装连接器(PPF:按照 Payment Provider Framework 安装步骤操作)。
  2. 为连接器注册 Gateway 关联。
  3. 在该关联上创建付款条件 Venda Direta Crédito 和/或 Venda Direta Débito。
  4. 在 Sales App 中显示这些付款方式(checkout-instore-custom.js)。标准条件 ID 为 44(借记卡)和 45(信用卡);请在 Admin 的 Payment Conditions 页面确认。
  5. 在设备上安装 VTEX Sales App,并在真实 POS 或认证测试 POS 上完成购买。
  6. 运行 Payment Provider Test Suite,包括异步批准/拒绝案例,然后在需要时提交认证工单。

相关技能

本技能的配套资料:

  • generation.md — 应提出的问题、克隆示例的工作流、实现顺序
  • ppf-scaffold.md — 为官方 PPF 示例应用叠加文件
  • examples.md — 合作伙伴提示词,以及 IO 与独立实现的载荷

其他支付 / Sales App 技能:

参考资料

评论

支持 Markdown