{
  "slug": "payment-gateway-stubs",
  "spec_file": "docs/specs/2026-05-31-payment-gateway-adapters.md",
  "summary": "Security-gate triage of the cardcom/payplus/stripe payment-gateway adapter stubs and their two live routes. One real fail-open was fixed (mock URL reachable in production); the remaining items are latent requirements recorded for when a real gateway is built, plus two owner decisions.",
  "findings": [
    {
      "id": "payment-gateway-stubs-001",
      "severity": "P1",
      "type": "security",
      "status": "fixed",
      "summary": "Stub payment adapters served a plausible-looking MOCK payment URL to real customers in production (fail-open)",
      "spec_ref": "2026-05-31-payment-gateway-adapters.md L14, L103-104 — settings UI exposes only built gateways; a gateway whose adapter is a stub must not be selectable; selecting a stub is impossible",
      "code_ref": "apps/zync-api/src/integrations/payment-gateways/{stripe,cardcom,payplus}.ts (generatePaymentLink stub return); reached via apps/zync-api/src/routes/invoices/payment-link.ts:101 and apps/zync-api/src/routes/portal/invoices.ts:158",
      "evidence": "upsertPaymentGatewaySchema.gateway = z.enum(['cardcom','payplus','stripe']) (packages/db/src/queries/payment-gateways.ts:26) — the ONLY allowed gateways are exactly the three unbuilt stubs. No 'built gateways' registry / server-side exclusion exists (grep BUILT_GATEWAYS|selectable|availableGateways → none). isActive is written verbatim from the API-reachable PATCH /api/settings/payment-gateway (upsertPaymentGatewayConfig). Both live routes gate only on gatewayConfig.isActive, then call the stub and return its mock URL (https://buy.stripe.com/mock/<id>, https://secure.cardcom.solutions/mock/<id>, https://payments.payplus.co.il/mock/<id>) to the customer as a Pay-Now redirect. The spec's 'stubs unselectable' rule was never enforced in code. Morning is the spec's only built gateway but lives in a different subsystem (invoice ISSUANCE: integrations/invoice-adapters) — there is no built payment-COLLECTION gateway at all.",
      "repro": "Tenant with settings:write → PATCH /api/settings/payment-gateway { gateway:'stripe', isActive:true, config:{secretKey:'x'} } → POST /api/invoices/:id/payment-link → 200 { url:'https://buy.stripe.com/mock/<id>' }. Same via portal POST /api/portal/invoices/:id/pay.",
      "fix": "Made all three stub adapters FAIL-CLOSED: generatePaymentLink now throws ('<gateway> is not configured for production ... refusing to issue a mock payment link') instead of returning a mock URL. Both live routes already wrap the adapter call in try/catch and surface a 502, so no mock URL can reach a customer. Fix is at the single adapter chokepoint (not the routes) so any future caller is guarded too. This restores the spec contract (L14/L103-104); no spec edit needed. The ACCEPTED-RISK (S9-i2-004) comment is preserved untouched — it is orthogonal (accepts the missing webhook/settlement path, not serving fake URLs).",
      "triage": "confirmed-code-fix"
    },
    {
      "id": "payment-gateway-stubs-002",
      "severity": "P2",
      "type": "deferred-implementation-requirement",
      "status": "deferred",
      "summary": "Latent implementation checklist + tests for when a real payment gateway adapter is built (replaces the fail-closed throw)",
      "spec_ref": "2026-05-31-payment-gateway-adapters.md — adapter contract, webhook security, idempotency",
      "code_ref": "apps/zync-api/src/integrations/payment-gateways/{stripe,cardcom,payplus}.ts (commented real-API blocks)",
      "requirements": [
        "Currency pass-through: Stripe uses invoice.currency (currency.toLowerCase()); Cardcom hardcodes CoinID '1' (ILS) in the commented block — must map invoice.currency, not assume ILS, or reject non-ILS explicitly.",
        "Integer money math: Stripe unit_amount must be Math.round(parseFloat(invoice.amount) * 100) — never float cents; amount is a string. Verify rounding/precision against the stored decimal to avoid off-by-one-agora errors.",
        "Workers env binding, NOT process.env: the commented Cardcom block reads process.env['APP_URL'] for redirect URLs and getEncryptionKey() reads process.env['INTEGRATION_ENCRYPTION_KEY'] (packages/db/src/queries/payment-gateways.ts:51) — on Cloudflare Workers config comes from c.env bindings, not process.env. Thread env through to the adapter; do not rely on process.env resolving.",
        "Guard parsed['url'] (Cardcom): commented block does `return { url: parsed['url']!, expiresAt: null }` with a non-null assertion after Object.fromEntries(URLSearchParams) — must check ResponseCode==='0' AND that 'url' is present before returning; never assert a possibly-absent field.",
        "Error-check BEFORE reading downstream fields (Stripe): commented block calls priceRes.json() then immediately uses price.id for the payment-link call with no status/error check — must verify priceRes.ok and price.id exists before the second call; a Stripe error body has no .id.",
        "Idempotency key: gateway link/charge creation must send an idempotency key (e.g. derived from invoice.id) so a retried request does not create a duplicate price/payment-link/charge.",
        "Webhook verification + settlement (S9-i2-004): add signed inbound webhook routes, provider re-fetch confirmation, idempotent recordInvoicePayment via the atomic conditional UPDATE (spec L313), per-tenant webhook token routing (spec L375). This is the ACCEPTED-RISK item.",
        "Encryption-key name mismatch: spec L444 names PAYMENT_CONFIG_ENCRYPTION_KEY; code (payment-gateways.ts:51) reads INTEGRATION_ENCRYPTION_KEY. Reconcile to one name before go-live."
      ],
      "tests": [
        "Adapter unit: a configured gateway returns a real provider URL (mock the fetch); an unconfigured/error response THROWS, never returns a mock or undefined URL.",
        "Route: POST /api/invoices/:id/payment-link and portal /:id/pay surface a non-200 (502/409) when the adapter throws — assert no mock-domain URL ever appears in any response body.",
        "Money: amount '100.005' and currency 'USD' produce the correct integer minor-units and correct currency on the provider call."
      ],
      "triage": "deferred"
    },
    {
      "id": "payment-gateway-stubs-003",
      "severity": "P2",
      "type": "owner-decision",
      "status": "open",
      "summary": "Spec-vs-code divergence: who is the built payment-COLLECTION gateway?",
      "spec_ref": "2026-05-31-payment-gateway-adapters.md L14 — 'Morning is the tier-1 adapter ... currently built end-to-end'",
      "evidence": "Spec says Morning is the built, selectable payment-collection gateway and stubs are excluded. Code has Morning only as invoice ISSUANCE (integrations/invoice-adapters), and all three payment-COLLECTION gateways (cardcom/payplus/stripe) are unbuilt stubs with no exclusion. Either 'no payment collection in production yet' is the intended current state (→ the fail-closed throw is the correct permanent end-state until a real gateway is built) OR Morning payment-collection was meant to exist (→ this is a missing feature, not just a stub). Decides whether stubs-001's fix is permanent or a stopgap.",
      "triage": "escalate-to-owner"
    }
  ]
}
