payment-ppp-pos

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

PPP Applied to POS (VTEX Sales App)

将 PPP 应用于 POS(VTEX Sales App)

When this skill applies

此 skill 的适用场景

Use this skill when the task is to create, extend, or debug a payment connector that charges a physical card on a POS terminal from VTEX Sales App.
  • The user mentions PPP + POS, POS connector, in-store payments, Sales App card payments, payment terminal, or
    Venda Direta Credito
    /
    Venda Direta Debito
  • The source of truth is Payment Provider Protocol for Point of Sale (POS)
  • The goal is to remove the "implement from scratch" barrier: clone the official PPF example from Payment Provider Framework Getting started, or overlay POS on existing PPP — not generate a generic ecommerce connector
Load companions when the step needs them. Do not dump all three into the first reply.
CompanionLoad when
generation.mdThe user asked to create a POS connector. Ask the four questions, then implement in the listed order.
ppf-scaffold.mdHosting is VTEX IO / PPF. Copy overlay files onto the official PPF example app.
examples.mdYou need expected JSON, the state machine table, or IO retry vs standalone notification.
The hard constraints below override ecommerce PPP and PCI habits. If a generated file violates one, STOP and fix it before writing more files.
Do not use this skill as the default choice for:
  • Ecommerce PPP endpoints, manifests, and homologation shapes with no POS methods — use
    payment-provider-protocol
  • IO connector wiring (
    PaymentProvider
    ,
    configuration.json
    ,
    this.retry
    ) — use
    payment-provider-framework
    together with this skill
  • Pix, Boleto, or browser-redirect async methods — use
    payment-async-flow
  • Ecommerce card data and Secure Proxy — use
    payment-pci-security
  • Sales App cart, PDP, or menu UI extensions — use
    sales-app-extensibility
当任务是从 VTEX Sales App 创建、扩展或调试一个通过 POS 终端对实体卡进行扣款的支付连接器时,使用此 skill。
  • 用户提到 PPP + POS、POS connector、门店支付、Sales App 卡支付、payment terminal,或
    Venda Direta Credito
    /
    Venda Direta Debito
  • 事实来源是面向 POS 的 Payment Provider Protocol
  • 目标是消除“从零实现”的障碍:从 Payment Provider Framework 入门指南克隆官方 PPF 示例,或将 POS 叠加到现有 PPP 上——而不是生成通用电商连接器
按具体步骤需要时加载配套 skill。不要在首次回复中一次性倾倒全部三个内容。
配套 skill何时加载
generation.md用户要求创建 POS 连接器。先询问四个问题,再按列出的顺序实现。
ppf-scaffold.md托管方式是 VTEX IO / PPF。将叠加文件复制到官方 PPF 示例应用。
examples.md需要预期 JSON、状态机表,或 IO 重试与独立通知的对比时。
以下硬性约束优先于电商 PPP 和 PCI 惯例。如果生成的文件违反其中任何一项,请立即停止并先修复,然后再编写更多文件。
不要将此 skill 作为以下场景的默认选择:
  • 不含 POS 方法的电商 PPP 端点、manifest 和 homologation 数据结构——使用
    payment-provider-protocol
  • IO 连接器配置(
    PaymentProvider
    、
    configuration.json
    、
    this.retry
    )——将
    payment-provider-framework
    与此 skill 配合使用
  • Pix、Boleto 或浏览器重定向异步方式——使用
    payment-async-flow
  • 电商卡数据和 Secure Proxy——使用
    payment-pci-security
  • Sales App 购物车、PDP 或菜单 UI 扩展——使用
    sales-app-extensibility

Decision rules

决策规则

  • Prefer VTEX IO / PPF unless the partner already hosts PPP middleware. Clone the official example app from the Payment Provider Framework guide and overlay POS; do not start from an empty Express app.
  • POS payments are still PPP. Implement the six payment-flow endpoints, then overlay the POS rules in this skill.
  • authorize()
    is a state machine. It never waits on the POS. It only returns the current phase. The connector answers Create Payment; it does not push the Gateway between phases. After the terminal app closes, VTEX Sales App triggers a Gateway callback that issues Create Payment (POS guide step 8). Wait for confirmation then polls the Gateway, which issues more Create Payment (step 9). Do not POST
    callbackUrl
    until the processor reports a final status — retry-on-pending is unnecessary and adds about 1 second per authorize.
Stored phaseWho drives the next Create PaymentCreate Payment returns
(none yet)Sales App checkout → Gateway (step 3)Persist
identify-terminal
;
undefined
+
vtex.terminal-connector-app
identify-terminal
(no serial)
Gateway / Sales AppSame terminal app until
submitUrl
stores a serial
identify-terminal
(serial stored)
Sales App after the terminal app closes (step 8)Start the processor charge once;
undefined
+
vtex.challenge-wait-for-confirmation
await-pos
Wait for confirmation polling (step 9)Same wait app; no second charge; no retry POST
approved
/
denied
Wait app / Sales App after IO retryFinal status +
cardBrand
+
firstDigits
+
lastDigits
  • Venda Direta Credito
    and
    Venda Direta Debito
    are asynchronous card-present methods. Return
    status: "undefined"
    and resolve later via
    callbackUrl
    . Do not treat them as sync Visa/Mastercard authorizations.
  • Branch on
    paymentMethod
    . On PPF, use
    isDirectSaleAuthorization
    from
    @vtex/payment-provider
    (canonical
    DirectSale
    names, and the narrowed type has no
    card
    /
    secureProxyUrl
    ). On standalone HTTP, compare to those same ASCII strings. A connector may support ecommerce and POS.
  • The Gateway does not receive the shopper's card for POS. All
    card
    fields arrive as
    null
    . Do not call Secure Proxy on this path.
  • Use VTEX's ready-made Payment Apps unless the partner has a documented reason to build a custom one:
    • Identify the terminal:
      vtex.terminal-connector-app
    • Wait for the POS result:
      vtex.challenge-wait-for-confirmation
  • Do not use
    vtex.challenge-terminal-connector-app
    . That name appears as a flow-diagram example in the POS guide; the implementation contract and the published app are
    vtex.terminal-connector-app
    .
  • paymentAppData.payload
    is a serialized JSON string, not a nested object. On PPF, build pending responses with
    Authorizations.pending
    so that is a compile error. Do not close those objects with
    as AuthorizationResponse
    — that cast hides the mistake.
    cardBrand
    ,
    firstDigits
    , and
    lastDigits
    are required by the POS guide but are not on the SDK response types (checked on
    @vtex/payment-provider
    1.4.0 and 1.8.0); the final approved/denied response is the one place a cast is justified.
  • Communication between the payment processor and the physical POS is outside VTEX. The connector only identifies the terminal, starts the processor transaction, and reports
    approved
    or
    denied
    to the Gateway.
  • Without VTEX IO,
    callbackUrl
    is a notification endpoint: POST the final status (include POS card fields). With VTEX IO,
    callbackUrl
    is a retry endpoint: persist state, then POST it with an empty body. Inside a
    PaymentProvider
    method use
    this.retry(request)
    . From a
    service.json
    extra route use
    new Payments(ctx.vtex).retry(callbackUrl)
    — same client as
    this.retry
    (gateway-retry metric, IO tracing, configured timeout). Do not POST
    status
    /
    cardBrand
    on IO. Return the final status plus POS card fields on the next Create Payment /
    authorize()
    .
  • POS serial collection and the processor webhook are extra public routes, not PPP inbound-request. The official example's
    this.callback(req, resp)
    is Test Suite only.
  • delayToCancel
    must be greater than or equal to
    secondsWaiting
    .
  • Every connector endpoint must respond in under 20 seconds in production (under 5 seconds during the Payment Provider Test Suite).
  • This skill can generate connector code and a test checklist. It cannot replace a real POS, a Sales App device, a configured test account, or homologation.
  • 除非合作方已经自行托管 PPP middleware,否则优先使用 VTEX IO / PPF。从 Payment Provider Framework 指南克隆官方示例应用并叠加 POS;不要从空白 Express app 开始。
  • POS 支付仍属于 PPP。先实现六个支付流程端点,然后叠加此 skill 中的 POS 规则。
  • authorize()
    是一个状态机。它绝不等待 POS 完成,只返回当前阶段。连接器负责响应 Create Payment;不会在两个阶段之间主动推动 Gateway。终端 app 关闭后,VTEX Sales App 会触发 Gateway callback,进而发起 Create Payment(POS guide 第 8 步)。随后,Wait for confirmation 轮询 Gateway,Gateway 会发出更多 Create Payment(第 9 步)。在 processor 报告最终状态前,不要 POST
    callbackUrl
    ;对 pending 状态执行重试没有必要,而且每次 authorize 会增加约 1 秒。
已存储阶段谁驱动下一次 Create PaymentCreate Payment 返回
(尚无)Sales App 结账 → Gateway(第 3 步)持久化
identify-terminal
;
undefined
+
vtex.terminal-connector-app
identify-terminal
(无 serial)
Gateway / Sales App继续使用同一个终端 app,直到
submitUrl
存储 serial
identify-terminal
(已存储 serial)
终端 app 关闭后的 Sales App(第 8 步)仅启动一次 processor 扣款;
undefined
+
vtex.challenge-wait-for-confirmation
await-pos
Wait for confirmation 轮询(第 9 步)同一个等待 app;不进行第二次扣款;不重试 POST
approved
/
denied
IO 重试后的 Wait app / Sales App最终状态 +
cardBrand
+
firstDigits
+
lastDigits
  • Venda Direta Credito
    和
    Venda Direta Debito
    是异步 card-present 方式。返回
    status: "undefined"
    ,并稍后通过
    callbackUrl
    完成。不要将它们视为同步 Visa/Mastercard 授权。
  • 根据
    paymentMethod
    分支。在 PPF 上,使用
    @vtex/payment-provider
    中的
    isDirectSaleAuthorization
    (它使用规范的
    DirectSale
    名称,且收窄后的类型没有
    card
    /
    secureProxyUrl
    )。在独立 HTTP 实现中,将
    paymentMethod
    与相同的两个 ASCII 字符串比较。连接器可以同时支持电商和 POS。
  • Gateway 不会收到 POS 购物者的卡数据。所有
    card
    字段均为
    null
    。不要在此路径上调用 Secure Proxy。
  • 除非合作方有记录在案的定制理由,否则使用 VTEX 官方现成的 Payment Apps:
    • 识别终端:
      vtex.terminal-connector-app
    • 等待 POS 结果:
      vtex.challenge-wait-for-confirmation
  • 不要使用
    vtex.challenge-terminal-connector-app
    。该名称仅作为 POS guide 中流程图的示例出现;实际实现约定和已发布的 app 是
    vtex.terminal-connector-app
    。
  • paymentAppData.payload
    是序列化后的 JSON 字符串,不是嵌套对象。在 PPF 上,使用
    Authorizations.pending
    构建 pending 响应,使传入对象产生编译错误。不要使用
    as AuthorizationResponse
    关闭这些对象;这种强制转换会隐藏错误。POS guide 要求提供
    cardBrand
    、
    firstDigits
    和
    lastDigits
    ,但 SDK 响应类型中没有这些字段(已检查
    @vtex/payment-provider
    1.4.0 和 1.8.0);只有在最终 approved/denied 响应中才适合使用强制转换。
  • 支付 processor 与实体 POS 之间的通信不在 VTEX 范围内。连接器只负责识别终端、启动 processor 交易,并向 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 tracing 和配置的 timeout)。在 IO 上不要 POST
    status
    /
    cardBrand
    。在下一次 Create Payment /
    authorize()
    中返回最终状态和 POS 卡字段。
  • POS serial 收集和 processor webhook 是额外的公共路由,不是 PPP inbound-request。官方示例中的
    this.callback(req, resp)
    仅供 Test Suite 使用。
  • delayToCancel
    必须大于或等于
    secondsWaiting
    。
  • 生产环境中,每个连接器端点都必须在 20 秒内响应;Payment Provider Test Suite 中必须在 5 秒内响应。
  • 此 skill 可以生成连接器代码和测试检查清单,但无法替代真实 POS、Sales App 设备、已配置的测试账户或 homologation。

Hard constraints

硬性约束

Code samples use example identifiers (
PartnerPos
,
partnername
,
connector-partnerpos
,
provider.example.com
) and example numbers (
SECONDS_WAITING = 600
,
firstDigits
/
lastDigits
such as
411111
/
1111
). Replace them with the partner's connector name, public hosts, timeouts, and processor card fragments.
600
is not a VTEX-required default; the rule is
delayToCancel >= secondsWaiting
. Example BIN/last-four values are truncated fragments, not a PAN.
代码示例使用示例标识符(
PartnerPos
、
partnername
、
connector-partnerpos
、
provider.example.com
)和示例数字(
SECONDS_WAITING = 600
,以及类似
411111
/
1111
的
firstDigits
/
lastDigits
)。将它们替换为合作方的连接器名称、公共 host、timeout 和 processor 卡片段。
600
不是 VTEX 要求的默认值;规则是
delayToCancel >= secondsWaiting
。示例 BIN/last-four 值是截断片段,不是 PAN。

Constraint: Manifest must declare the POS payment methods by exact API name

约束:Manifest 必须使用精确 API 名称声明 POS 支付方式

GET
/manifest
(and PPF
paymentProvider/configuration.json
paymentMethods
) MUST include
Venda Direta Credito
and/or
Venda Direta Debito
with the exact ASCII names from the POS guide. Admin labels with accents (
Venda Direta Crédito
,
Venda Direta Débito
) are display names only.
Why this matters The Gateway and Sales App payment conditions bind to these method names. If the manifest only lists
Visa
/
Mastercard
, merchants cannot configure POS. If the connector invents a name such as
"POS Credit"
, Admin and Sales App will not attach the in-store flow.
Detection If the task is POS and
paymentMethods
lacks
Venda Direta Credito
or
Venda Direta Debito
, STOP and add them before generating other code.
Correct
json
{
  "paymentMethods": [
    { "name": "Venda Direta Debito", "allowsSplit": "onCapture" },
    { "name": "Venda Direta Credito", "allowsSplit": "onCapture" }
  ]
}
Wrong
json
{
  "paymentMethods": [
    { "name": "Visa", "allowsSplit": "onCapture" },
    { "name": "Mastercard", "allowsSplit": "onCapture" },
    { "name": "POS Credit", "allowsSplit": "onCapture" }
  ]
}
GET
/manifest
(以及 PPF
paymentProvider/configuration.json
的
paymentMethods
)必须包含 POS guide 中精确的 ASCII 名称
Venda Direta Credito
和/或
Venda Direta Debito
。带重音符号的 Admin 标签(
Venda Direta Crédito
、
Venda Direta Débito
)仅用于显示。
为什么这很重要
Gateway 和 Sales App 的支付条件绑定到这些方式名称。如果 manifest 只列出
Visa
/
Mastercard
,商家就无法配置 POS。如果连接器自创诸如
POS Credit
的名称,Admin 和 Sales App 将无法附加门店支付流程。
检测方式
如果任务是 POS,但
paymentMethods
中缺少
Venda Direta Credito
或
Venda Direta Debito
,请立即停止,并在生成其他代码前补充它们。
正确
json
{
  "paymentMethods": [
    { "name": "Venda Direta Debito", "allowsSplit": "onCapture" },
    { "name": "Venda Direta Credito", "allowsSplit": "onCapture" }
  ]
}
错误
json
{
  "paymentMethods": [
    { "name": "Visa", "allowsSplit": "onCapture" },
    { "name": "Mastercard", "allowsSplit": "onCapture" },
    { "name": "POS Credit", "allowsSplit": "onCapture" }
  ]
}

Constraint: Detect POS from
paymentMethod
and never read card PAN from the Gateway

约束:通过 paymentMethod 检测 POS,绝不能从 Gateway 读取卡 PAN

For
Venda Direta Credito
and
Venda Direta Debito
, Create Payment sends
card.holder
,
card.number
,
card.csc
,
card.expiration
,
card.document
, and
card.token
as
null
. The connector MUST NOT require those fields, MUST NOT call Secure Proxy, and MUST NOT decline the payment because card data is missing.
On PPF, branch with
isDirectSaleAuthorization
from
@vtex/payment-provider
. That guard uses the SDK
DirectSale
names (exact ASCII), and
DirectSaleAuthorization
has no
card
and no
secureProxyUrl
— so
authorization.card
is a compile error after the narrow. Do not hand-roll
POS_METHODS
or
(authorization as any).paymentMethod
. On standalone HTTP, compare
paymentMethod
to those same two strings.
callbackUrl
and
value
are already on
Authorization
; do not cast them.
Why this matters The shopper pays on the physical terminal. Treating POS as an ecommerce card authorization fails the charge, or worse, invents Secure Proxy calls with empty tokens. A hand-rolled accented string fails at the Gateway instead of at compile time.
Detection If POS authorize logic reads
card.number
/
secureProxyUrl
or returns
denied
when
card
is null, STOP and split POS from ecommerce. If PPF code uses
as any
to read
paymentMethod
,
callbackUrl
, or
value
, STOP and use the typed fields / guard.
Correct
typescript
import {
  isDirectSaleAuthorization,
  AuthorizationRequest,
} from "@vtex/payment-provider";

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

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

  return secureProxy.charge(request.card, request.secureProxyUrl);
}
对于
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 当作电商卡授权会导致扣款失败,甚至在 token 为空时错误地发起 Secure Proxy 调用。手写带重音符号的字符串会直接导致 Gateway 失败,而不是在编译期失败。
检测方式
如果 POS authorize 逻辑读取
card.number
/
secureProxyUrl
,或者在
card
为 null 时返回
denied
,请立即停止并将 POS 与电商流程分离。如果 PPF 代码使用
as any
读取
paymentMethod
、
callbackUrl
或
value
,请立即停止并改用有类型的字段或守卫。
正确
typescript
import {
  isDirectSaleAuthorization,
  AuthorizationRequest,
} from "@vtex/payment-provider";

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

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

  return secureProxy.charge(request.card, request.secureProxyUrl);
}

Constraint: First Create Payment response must open the terminal Payment App

约束:首次 Create Payment 响应必须打开终端 Payment App

When the connector has not yet received the POS serial number for this
paymentId
, Create Payment MUST return
status: "undefined"
and
paymentAppData
for
vtex.terminal-connector-app
.
payload
MUST be a JSON string with
submitUrl
, the HTTPS endpoint that receives
{"serialNumber": "12345"}
.
Replace the host in examples with the partner's real public URL. Persist
callbackUrl
and
paymentId
before responding.
On PPF, return
Authorizations.pending(authorization, { delayToCancel, paymentAppData })
.
AppData.payload
is
Maybe<string>
, so an object payload is a compile error. Do not wrap that object in
as AuthorizationResponse
.
Why this matters Without this response, Sales App never opens the camera/barcode challenge, so the processor never learns which terminal to use. A nested
payload
object is not injected into the Payment App. A whole-response cast turns that compile error off.
Detection If the first POS response is
approved
/
denied
, omits
paymentAppData
, uses
vtex.challenge-terminal-connector-app
, sets
payload
as an object, or uses
as AuthorizationResponse
on a pending body, STOP and fix.
Correct
typescript
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);
}
Wrong
typescript
return {
  paymentId: request.paymentId,
  status: "approved",
  paymentAppData: {
    appName: "vtex.challenge-terminal-connector-app",
    payload: {
      submitUrl: "https://provider.example.com/pos/serial",
    },
  },
};
当连接器尚未收到某个
paymentId
对应的 POS serial number 时,Create Payment 必须返回
undefined
状态,以及用于
vtex.terminal-connector-app
的
paymentAppData
。
payload
必须是 JSON 字符串,其中包含用于接收
{"serialNumber": "12345"}
的 HTTPS 端点
submitUrl
。
将示例中的 host 替换为合作方真实的公共 URL。在响应前持久化
callbackUrl
和
paymentId
。
在 PPF 上,返回
Authorizations.pending(authorization, { delayToCancel, paymentAppData })
。
AppData.payload
的类型是
Maybe<string>
,因此传入对象 payload 会产生编译错误。不要使用
as AuthorizationResponse
包装该对象。
为什么这很重要
如果没有此响应,Sales App 永远不会打开摄像头/条形码 challenge,processor 因而无法得知应使用哪个终端。嵌套的
payload
对象不会被注入 Payment App。对整个响应进行强制转换会关闭此编译错误。
检测方式
如果首次 POS 响应是
approved
/
denied
、缺少
paymentAppData
、使用
vtex.challenge-terminal-connector-app
、将
payload
设为对象,或者对 pending 响应体使用
as AuthorizationResponse
,请立即停止并修复。
正确
typescript
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);
}
错误
typescript
return {
  paymentId: request.paymentId,
  status: "approved",
  paymentAppData: {
    appName: "vtex.challenge-terminal-connector-app",
    payload: {
      submitUrl: "https://provider.example.com/pos/serial",
    },
  },
};

Constraint: After the serial number, wait with
vtex.challenge-wait-for-confirmation

约束:获取 serial number 后,使用 vtex.challenge-wait-for-confirmation 等待

When
submitUrl
receives
{"serialNumber": "..."}
, the connector MUST associate that serial with
paymentId
(POS guide step 7). Do not start the POS charge in that handler.
The next Create Payment — driven by Sales App after the terminal app closes (step 8) — MUST start the processor charge once, then return
status: "undefined"
plus
vtex.challenge-wait-for-confirmation
with payload
{"secondsWaiting": <int>}
(steps 8.b–8.d). Later polls (step 9) MUST NOT start another charge and MUST NOT POST
callbackUrl
.
Do not start the POS charge before the serial number exists, and do not start it from the first
authorize()
(no serial yet).
secondsWaiting
is how long the Wait for confirmation app polls.
delayToCancel
(seconds, Create Payment response) is how long the Gateway keeps the payment
undefined
before calling Cancel. Set
delayToCancel >= secondsWaiting
.
Why this matters Starting the terminal before it is identified charges the wrong device or none. Starting the charge in
submitUrl
diverges from the published sequence (8.b starts the payment, 8.c confirms it started, 8.d returns the wait app). If
delayToCancel
is shorter than
secondsWaiting
, the Gateway cancels while Sales App is still waiting. If Create Payment starts a new processor charge on every poll, the shopper is charged twice.
Detection If Wait for confirmation is returned before a serial is stored, if
startPosCharge
runs in the serial handler or on every poll, if
secondsWaiting
is missing, or if
delayToCancel < secondsWaiting
, STOP.
Correct
typescript
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 }),
    },
  });
}
Wrong
typescript
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 }),
    },
  };
}
当
submitUrl
收到
{"serialNumber": "..."}
时,连接器必须将该 serial 与
paymentId
关联(POS guide 第 7 步)。不要在该处理器中启动 POS 扣款。
由 Sales App 在终端 app 关闭后驱动的下一次 Create Payment(第 8 步)必须仅启动一次 processor 扣款,然后返回
undefined
状态和
vtex.challenge-wait-for-confirmation
,其 payload 为
{"secondsWaiting": <int>}
(第 8.b–8.d 步)。后续轮询(第 9 步)绝不能启动第二次扣款,也绝不能 POST
callbackUrl
。
在 serial number 存在之前不要启动 POS 扣款,也不要在首次
authorize()
中启动它。
secondsWaiting
表示 Wait for confirmation app 的轮询时长。
delayToCancel
(Create Payment 响应中的秒数)表示 Gateway 在调用 Cancel 之前将支付保持为
undefined
的时长。设置
delayToCancel >= secondsWaiting
。
为什么这很重要
在识别终端之前启动终端操作,可能对错误的设备扣款,或根本没有设备可扣款。在
submitUrl
中启动扣款会偏离已发布的流程顺序(第 8.b 步启动支付,第 8.c 步确认已启动,第 8.d 步返回等待 app)。如果
delayToCancel
小于
secondsWaiting
,Gateway 会在 Sales App 仍在等待时取消支付。如果每次 Create Payment 轮询都创建新的 processor 交易,购物者就会被重复扣款。
检测方式
如果在存储 serial 之前返回 Wait for confirmation、在 serial 处理器或每次轮询中运行
startPosCharge
、缺少
secondsWaiting
,或者
delayToCancel < secondsWaiting
,请立即停止。
正确
typescript
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 }),
    },
  });
}
错误
typescript
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 }),
    },
  };
}

Constraint: Approve or deny only through the mandatory callback, including POS card fields

约束:只能通过必需的 callback 执行批准或拒绝,并包含 POS 卡字段

The processor, not VTEX, reads the physical card. When it reports a final result, the connector MUST notify the Gateway. POS cannot be approved or denied without this callback. Include:
  • status
    :
    "approved"
    or
    "denied"
  • cardBrand
  • firstDigits
    (first six digits / BIN)
  • lastDigits
    (last four digits)
Those three card fields are required for merchant reconciliation. They come from the processor after the terminal reads the card — not from the original Create Payment
card
object.
Do not log or store the full PAN.
firstDigits
+
lastDigits
are the allowed fragments.
Why this matters Without notifying the Gateway, Sales App stays on Wait for confirmation until timeout, then cancels. Missing BIN/last-four breaks financial reports even when the charge succeeded on the terminal. On IO, posting a standalone notification body to
callbackUrl
does not complete the flow — the Gateway expects a bodiless retry POST, then a new
authorize()
.
Detection If POS code returns
approved
on the first Create Payment, STOP. If PPF code
fetch
es
callbackUrl
with
status
/
cardBrand
in the body, STOP and use
Payments.retry
/
this.retry
. If a Service route calls
this.retry
(there is no
this
), STOP and use
new Payments(ctx.vtex).retry(stored.callbackUrl)
. If pending
authorize()
POSTs
callbackUrl
to "nudge" the Gateway, STOP. If standalone code omits the three card fields on the notification POST, STOP.
Correct (PPF extra route — typical processor webhook)
typescript
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);
Correct (inside a
PaymentProvider
method only)
typescript
return this.retry(authorization);
The next
authorize()
MUST return
status
,
cardBrand
,
firstDigits
, and
lastDigits
from the persisted record. See examples.md for the standalone notification payload.
Wrong
typescript
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",
  }),
});
读取实体卡的是 processor,而不是 VTEX。当 processor 报告最终结果时,连接器必须通知 Gateway。没有此 callback,POS 就不能被批准或拒绝。必须包含:
  • status
    :
    "approved"
    或
    "denied"
  • cardBrand
  • firstDigits
    (前六位 / BIN)
  • lastDigits
    (后四位)
这三个卡字段是商家对账所必需的。它们来自终端读取卡片后的 processor 结果,而不是最初 Create Payment 中的
card
对象。
不要记录或存储完整 PAN。允许存储的片段是
firstDigits
+
lastDigits
。
为什么这很重要
如果不通知 Gateway,Sales App 会一直停留在 Wait for confirmation,直到超时后被取消。缺少 BIN/last-four 会导致财务报告出错,即使终端扣款已经成功。在 IO 上,向
callbackUrl
POST 独立通知请求体无法完成流程——Gateway 期望收到一个无请求体的重试 POST,随后发起新的
authorize()
。
检测方式
如果 POS 代码在首次 Create Payment 时返回
approved
,请立即停止。如果 PPF 代码使用带有
status
/
cardBrand
请求体的
fetch
调用
callbackUrl
,请立即停止并改用
Payments.retry
/
this.retry
。如果 Service 路由调用
this.retry
(此处不存在
this
),请立即停止并改用
new Payments(ctx.vtex).retry(stored.callbackUrl)
。如果 pending
authorize()
POST
callbackUrl
来“推动”Gateway,请立即停止。如果独立部署代码的通知 POST 缺少三个卡字段,请立即停止。
正确(PPF 额外路由——典型 processor webhook)
typescript
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 方法内部)
typescript
return this.retry(authorization);
下一次
authorize()
必须从持久化记录返回
status
、
cardBrand
、
firstDigits
和
lastDigits
。有关独立通知 payload,请参阅 examples.md。
错误
typescript
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",
  }),
});

Constraint: Keep Create Payment idempotent and answers under 20 seconds

约束:保持 Create Payment 幂等,并在 20 秒内响应

Create Payment for a given
paymentId
MUST return the current stored phase and status. It MUST NOT create a second processor transaction. Polling from Wait for confirmation will call Create Payment many times while status is still
undefined
. Those calls can overlap (different
requestId
, same
paymentId
) — a sequential get-then-save can create two records or drop a phase update.
On PPF, persist with
getRawJSON
(capture
etag
) then
saveJSON(..., ifMatch)
. On 412, re-read and continue from the stored document. Pair with
payment-idempotency
. Do not put unique per-request fields (random
tid
,
requestId
) on the first identify-terminal write.
VBase composes the metadata bucket as
{vendor}.{appName}.{bucket}
and rejects names longer than 50 characters (
400 Metadata bucket name too large
). Use a short bucket such as
pos
. The skill placeholders (
partnername.connector-partnerpos
) fit
pos-payments
; a longer real
vendor.appName
does not.
Do not wait for the shopper to finish on the POS inside the HTTP handler. Compare-and-set
identify-terminal
→
await-pos
, start the processor charge only as the winner of that write, and return. Password retries and insufficient-funds handling stay between the processor and the POS.
Why this matters The Gateway treats a >20s response as failure. Duplicate POS charges happen when each poll hits the acquirer again. Overlapping first writes for one
paymentId
return inconsistent
tid
s and break PPP idempotency. A long VBase bucket fails on the first Create Payment with an opaque 400.
Detection If the handler
await
s the terminal result, calls
processor.startPosCharge
when
phase
is already
await-pos
or final, uses
getJSON
+
saveJSON
with no
etag
, or uses a VBase bucket that makes
{vendor}.{appName}.{bucket}
longer than 50 characters, STOP.
Correct
typescript
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}`,
      }),
    },
  });
}
Wrong
typescript
async function authorizePos(request: CreatePaymentRequest) {
  const result = await processor.startPosCharge(request);
  await processor.waitUntilCardFinished(result, { timeoutMs: 120000 });
  return { paymentId: request.paymentId, status: result.status };
}
对于给定的
paymentId
,Create Payment 必须返回当前存储的阶段和状态。它绝不能创建第二个 processor 交易。Wait for confirmation 会在状态仍为
undefined
时多次调用 Create Payment。这些调用可能重叠(
requestId
不同,但
paymentId
相同)——顺序执行的先获取再保存可能创建两条记录或丢失阶段更新。
在 PPF 上,使用
getRawJSON
读取并持久化(获取
etag
),然后通过
saveJSON(..., ifMatch)
保存。遇到 412 时,重新读取并从已存储文档继续执行。与
payment-idempotency
配合使用。不要在首次 identify-terminal 写入中放入按请求唯一的字段(随机
tid
、
requestId
)。
VBase 会将元数据 bucket 组合为
{vendor}.{appName}.{bucket}
,并拒绝超过 50 个字符的名称(
400 Metadata bucket name too large
)。使用短 bucket,例如
pos
。此 skill 中的占位符(
partnername.connector-partnerpos
)可以用于
pos-payments
;更长的真实
vendor.appName
则不可以。
不要在 HTTP 处理器中等待购物者在 POS 上完成操作。通过 compare-and-set 将
identify-terminal
转换为
await-pos
,只有写入成功的请求才能启动 processor 扣款,然后立即返回。密码重试和余额不足处理应留在 processor 与 POS 之间完成。
为什么这很重要
Gateway 会将超过 20 秒的响应视为失败。当每次轮询都访问 acquirer 时,会发生重复 POS 扣款。对于同一个
paymentId
,重叠的首次写入会返回不一致的
tid
,从而破坏 PPP 幂等性。过长的 VBase bucket 会导致首次 Create Payment 出现不透明的 400 错误。
检测方式
如果处理器
await
终端结果、在
phase
已经是
await-pos
或最终状态时调用
processor.startPosCharge
、使用没有
etag
的
getJSON
+
saveJSON
,或者使用会导致
{vendor}.{appName}.{bucket}
超过 50 个字符的 VBase bucket,请立即停止。
正确
typescript
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}`,
      }),
    },
  });
}
错误
typescript
async function authorizePos(request: CreatePaymentRequest) {
  const result = await processor.startPosCharge(request);
  await processor.waitUntilCardFinished(result, { timeoutMs: 120000 });
  return { paymentId: request.paymentId, status: result.status };
}

Constraint: Collect the POS serial on a public custom route, not inbound-request

约束:通过公共自定义路由收集 POS serial,而不是 inbound-request

vtex.terminal-connector-app
POSTs
{"serialNumber":"..."}
to
submitUrl
. That URL MUST be a public HTTPS endpoint the Payment App can call. On PPF, declare it in
service.json
routes
and pass it into
PaymentProviderService
. Do not use
POST /payments/{paymentId}/inbound-request/{action}
for this step.
Why this matters Inbound-request is Gateway-initiated. The terminal app posts from the Sales App device. If
submitUrl
is an inbound path, the serial never arrives and the POS charge never starts.
Detection If
submitUrl
contains
/inbound
, STOP. If PPF has no extra
service.json
route for serial collection, STOP.
Correct
json
{
  "routes": {
    "posSerial": {
      "path": "/_v/partnerpos/pos/serial/:paymentId",
      "public": true
    }
  }
}
Wrong
json
{
  "submitUrl": "https://account.myvtex.com/_v/api/my-connector/payments/PAY1/inbound/serial"
}
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 发起,而终端 app 是从 Sales App 设备发起 POST。如果
submitUrl
使用 inbound 路径,serial 将永远不会到达,POS 扣款也永远不会启动。
检测方式
如果
submitUrl
中包含
/inbound
,请立即停止。如果 PPF 没有用于收集 serial 的额外
service.json
路由,请立即停止。
正确
json
{
  "routes": {
    "posSerial": {
      "path": "/_v/partnerpos/pos/serial/:paymentId",
      "public": true
    }
  }
}
错误
json
{
  "submitUrl": "https://account.myvtex.com/_v/api/my-connector/payments/PAY1/inbound/serial"
}

Preferred pattern

推荐模式

Follow generation.md in order. For PPF, overlay POS on the official example app from the Payment Provider Framework guide using ppf-scaffold.md. Compare IO vs standalone payloads in examples.md.
text
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
按照 generation.md 中列出的顺序执行。对于 PPF,按照 ppf-scaffold.md,将 POS 叠加到 Payment Provider Framework 指南中的官方 PPF 示例应用上。使用 examples.md 比较 IO 与独立部署的 payload。
text
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

Common failure modes

常见失败模式

  • Generating an ecommerce PPP connector (Visa/Mastercard + Secure Proxy) when the user asked for PPP + POS.
  • Omitting
    Venda Direta Credito
    /
    Venda Direta Debito
    from the manifest, or using accented Admin labels as API names.
  • Returning
    approved
    before the terminal finishes, or waiting inside Create Payment for the card to be inserted.
  • Using
    vtex.challenge-terminal-connector-app
    instead of
    vtex.terminal-connector-app
    .
  • Sending
    paymentAppData.payload
    as an object instead of
    JSON.stringify(...)
    , or hiding that with
    as AuthorizationResponse
    on a pending body.
  • Setting
    delayToCancel
    below
    secondsWaiting
    , so the Gateway cancels a payment the Wait app is still polling.
  • Starting the POS charge in the
    submitUrl
    handler (POS guide step 8.b starts it on the following Create Payment).
  • Starting a new POS charge on every Create Payment poll (broken idempotency).
  • POSTing
    callbackUrl
    on pending responses to "nudge" the Gateway. Sales App and Wait for confirmation already drive Create Payment.
  • Using
    getJSON
    +
    saveJSON
    with no
    etag
    while Create Payment calls overlap for one
    paymentId
    .
  • Using a VBase bucket that makes
    {vendor}.{appName}.{bucket}
    longer than 50 characters (default to
    pos
    ).
  • Skipping the Gateway callback, or omitting
    cardBrand
    ,
    firstDigits
    , and
    lastDigits
    .
  • On VTEX IO: POSTing a standalone notification payload (
    status
    + card fields) to
    callbackUrl
    instead of a bodiless
    Payments.retry
    /
    this.retry
    .
  • Copying the example's Test Suite
    this.callback(req, resp)
    as the POS processor webhook.
  • Calling
    this.retry
    from a
    service.json
    extra route (no
    PaymentProvider
    this
    ). Use
    new Payments(ctx.vtex).retry(callbackUrl)
    there.
  • Putting
    submitUrl
    on PPP inbound-request instead of a public custom route.
  • Applying
    payment-pci-security
    Secure Proxy to POS authorize because "it is a card payment".
  • Building a Sales App cart/PDP extension instead of a payment connector.
  • Starting from an empty Express app when the partner can use PPF / the official example app.
  • 用户要求 PPP + POS,却生成电商 PPP 连接器(Visa/Mastercard + Secure Proxy)。
  • Manifest 中缺少
    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
    隐藏 pending 响应体中的错误。
  • 将
    delayToCancel
    设为低于
    secondsWaiting
    ,导致 Gateway 在 Wait app 仍在轮询时取消支付。
  • 在
    submitUrl
    处理器中启动 POS 扣款(POS guide 第 8.b 步要求在下一次 Create Payment 中启动)。
  • 每次 Create Payment 轮询都启动新的 POS 扣款(幂等性被破坏)。
  • 在 pending 响应中 POST
    callbackUrl
    来“推动”Gateway。Sales App 和 Wait for confirmation 本身会驱动 Create Payment。
  • 在 Create Payment 调用可能重叠时,使用没有
    etag
    的
    getJSON
    +
    saveJSON
    。
  • 使用会导致
    {vendor}.{appName}.{bucket}
    超过 50 个字符的 VBase bucket(默认使用
    pos
    )。
  • 跳过 Gateway callback,或者缺少
    cardBrand
    、
    firstDigits
    和
    lastDigits
    。
  • **在 VTEX IO 上:**向
    callbackUrl
    POST 独立通知请求体(
    status
    + 卡字段),而不是执行无请求体的
    Payments.retry
    /
    this.retry
    。
  • 将示例中的 Test Suite
    this.callback(req, resp)
    误用作 POS processor 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 app 开始。

Review checklist

审查检查清单

  • For PPF, was the app overlaid on the official PPF example (or an existing PPF connector), not an empty HTTP server?
  • Does
    /manifest
    (and IO
    configuration.json
    ) declare
    Venda Direta Credito
    and/or
    Venda Direta Debito
    with those exact names?
  • Does authorize branch with
    isDirectSaleAuthorization
    (PPF) or the exact ASCII method names (standalone), and skip Gateway card PAN / Secure Proxy for POS?
  • Is the first POS response
    Authorizations.pending
    +
    vtex.terminal-connector-app
    with string
    payload.submitUrl
    (no whole-response cast)?
  • Is
    submitUrl
    a public custom route (
    service.json
    ) that accepts
    {"serialNumber": "..."}
    and is not inbound-request?
  • Does
    submitUrl
    only persist the serial (step 7), and does the next Create Payment start the processor charge once (step 8.b)?
  • Is the waiting response
    undefined
    +
    vtex.challenge-wait-for-confirmation
    with
    secondsWaiting
    ?
  • Is
    delayToCancel >= secondsWaiting
    , and does every endpoint respond in under 20 seconds?
  • Are overlapping Create Payment calls for the same
    paymentId
    safe (
    getRawJSON
    +
    ifMatch
    , no second POS charge, no retry-on-pending)?
  • Is the PPF VBase bucket short enough that
    {vendor}.{appName}.{bucket}
    is ≤ 50 characters (use
    pos
    )?
  • PPF: processor webhook persists card fragments then
    Payments.retry(callbackUrl)
    (or
    this.retry
    inside
    PaymentProvider
    )? Next
    authorize()
    returns the card fragments (cast only that final response)?
  • Standalone: notification POST includes
    status
    ,
    cardBrand
    ,
    firstDigits
    ,
    lastDigits
    ?
  • Is Cancel implemented so Gateway timeout can abort an
    undefined
    POS payment?
  • Has the partner been told they still need a Sales App device, POS terminal, test account, payment conditions, and homologation?
  • 对于 PPF,应用是否叠加在官方 PPF 示例(或现有 PPF 连接器)上,而不是空白 HTTP server?
  • /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
    是否为接受
    {"serialNumber": "..."}
    的公共自定义路由(
    service.json
    ),而不是 inbound-request?
  • submitUrl
    是否只持久化 serial(第 7 步),并由下一次 Create Payment 只启动一次 processor 扣款(第 8.b 步)?
  • 等待响应是否为
    undefined
    +
    vtex.challenge-wait-for-confirmation
    ,并包含
    secondsWaiting
    ?
  • 是否满足
    delayToCancel >= secondsWaiting
    ,且每个端点都在 20 秒内响应?
  • 同一个
    paymentId
    的重叠 Create Payment 调用是否安全(
    getRawJSON
    +
    ifMatch
    、不进行第二次 POS 扣款、不在 pending 状态重试)?
  • PPF VBase bucket 是否足够短,使
    {vendor}.{appName}.{bucket}
    不超过 50 个字符(使用
    pos
    )?
  • **PPF:**processor webhook 是否先持久化卡片段,再执行
    Payments.retry(callbackUrl)
    (或在
    PaymentProvider
    内执行
    this.retry
    )?下一次
    authorize()
    是否返回卡片段(仅对最终响应强制转换)?
  • **独立部署:**通知 POST 是否包含
    status
    、
    cardBrand
    、
    firstDigits
    和
    lastDigits
    ?
  • 是否实现了 Cancel,使 Gateway timeout 能够中止
    undefined
    状态的 POS 支付?
  • 是否已告知合作方仍需准备 Sales App 设备、POS 终端、测试账户、支付条件和 homologation?

Testing prerequisites (cannot be automated by the skill)

测试前置条件(此 skill 无法自动化)

VTEX often provides a preconfigured store for connector testing. To configure a store manually:
  1. Install the connector (PPF: follow the Payment Provider Framework install steps).
  2. Register a Gateway affiliation for the connector.
  3. Create payment conditions Venda Direta Crédito and/or Venda Direta Débito on that affiliation.
  4. Show those methods on Sales App (
    checkout-instore-custom.js
    ). Standard condition IDs are
    44
    (debit) and
    45
    (credit); confirm on the Admin Payment Conditions page.
  5. Install VTEX Sales App on a device and complete a purchase on a real or certified test POS.
  6. Run the Payment Provider Test Suite, including async approved/denied cases, then open a homologation ticket when required.
VTEX 通常会提供一个预配置的商店用于测试连接器。要手动配置商店:
  1. 安装连接器(PPF:遵循 Payment Provider Framework 安装步骤)。
  2. 为连接器注册 Gateway affiliation。
  3. 在该 affiliation 上创建支付条件 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 完成一次购买。
  6. 运行 Payment Provider Test Suite,包括异步 approved/denied 用例;如有需要,再创建 homologation ticket。

Related skills

相关 skills

Companions for this skill:
  • generation.md — Questions to ask, clone-the-example workflow, implementation order
  • ppf-scaffold.md — Overlay files for the official PPF example app
  • examples.md — Partner prompts and IO vs standalone payloads
Other payment / Sales App skills:
  • payment-provider-protocol
    — Mandatory PPP endpoints and response shapes; use together with this skill
  • payment-provider-framework
    — Use when the POS connector is a VTEX IO app
  • payment-async-flow
    — Generic
    undefined
    +
    callbackUrl
    rules; POS adds Payment Apps and card-fragment callbacks on top
  • payment-idempotency
    —
    paymentId
    must not create a second POS charge; Create Payment for one id can overlap (etag /
    ifMatch
    )
  • payment-pci-security
    — Ecommerce card-not-present only; do not apply Secure Proxy to POS authorize
  • sales-app-extensibility
    — Sales App UI extensions; not the POS payment connector
此 skill 的配套内容:
  • generation.md——需要询问的问题、克隆示例工作流以及实现顺序
  • ppf-scaffold.md——官方 PPF 示例应用的叠加文件
  • examples.md——合作方提示以及 IO 与独立部署的 payload
其他支付 / Sales App skills:
  • payment-provider-protocol
    ——必需的 PPP 端点和响应结构;与此 skill 配合使用
  • payment-provider-framework
    ——当 POS 连接器是 VTEX IO app 时使用
  • payment-async-flow
    ——通用的
    undefined
    +
    callbackUrl
    规则;POS 在此基础上增加了 Payment Apps 和卡片段 callback
  • payment-idempotency
    ——
    paymentId
    绝不能创建第二次 POS 扣款;同一个 id 的 Create Payment 调用可能重叠(etag /
    ifMatch
    )
  • payment-pci-security
    ——仅用于电商 card-not-present;不要在 POS authorize 中应用 Secure Proxy
  • sales-app-extensibility
    ——Sales App UI 扩展;不是 POS 支付连接器

Reference

参考资料