{
  "format": "opex-flow-package/1",
  "payload": {
    "contents": {
      "apps": [],
      "events": [
        {
          "description": "A confirmed payment was refunded in full or in part by the payment gateway. Finance books it against the original payment (payment_id) in the ERP.",
          "example": {
            "amount": 120000,
            "currency": "IDR",
            "gateway": "DOKU",
            "gateway_reference": "DK-7781999",
            "order_id": "INV-2026-000123",
            "payment_id": "PAY-20261030-0001",
            "reason": "Item returned",
            "refund_id": "RF-20261030-0007",
            "refunded_at": "2026-10-31T10:02:44+07:00"
          },
          "name": "payment.refunded",
          "object": "payment",
          "owner": "Finance / Treasury",
          "schema": {
            "$schema": "https://json-schema.org/draft/2020-12/schema",
            "properties": {
              "amount": {
                "description": "Refunded amount, in the payment's currency",
                "exclusiveMinimum": 0,
                "type": "number"
              },
              "currency": {
                "pattern": "^[A-Z]{3}$",
                "type": "string"
              },
              "gateway": {
                "type": "string"
              },
              "gateway_reference": {
                "type": "string"
              },
              "order_id": {
                "type": "string"
              },
              "payment_id": {
                "description": "The payment.confirmed it reverses",
                "minLength": 1,
                "type": "string"
              },
              "reason": {
                "type": "string"
              },
              "refund_id": {
                "description": "Id of the refund at the gateway",
                "minLength": 1,
                "type": "string"
              },
              "refunded_at": {
                "format": "date-time",
                "type": "string"
              }
            },
            "required": [
              "refund_id",
              "payment_id",
              "amount",
              "currency",
              "refunded_at"
            ],
            "type": "object"
          },
          "title": "Payment refunded",
          "version": 1
        }
      ],
      "examples": [
        {
          "audience": "business",
          "description": "The signed webhook source verifies the HMAC signature. Two sub-flows of the Payments webhooks package do the rest: Once per delivery drops a redelivery, Normalise and validate turns a Midtrans / Xendit / DOKU-style body into payment.confirmed and quarantines a bad one with the reasons. An HTTP node posts it to the ERP against the package's OpenAPI contract (erp-payments#postPayment), so a request or response that breaks the contract fails the record. The ERP answers from a bundled mock until you turn the mock off.",
          "flow": {
            "edges": [
              {
                "from": "src",
                "id": "src-once",
                "to": "once"
              },
              {
                "from": "once",
                "id": "once-normalize",
                "to": "normalize"
              },
              {
                "from": "normalize",
                "id": "normalize-erp",
                "to": "erp"
              }
            ],
            "flows": [
              {
                "id": "main",
                "name": "Payment webhook to ERP"
              }
            ],
            "nodes": [
              {
                "h": 64,
                "id": "note",
                "text": "Signature checked at the door -> once per delivery -> normalised + validated as payment.confirmed -> posted to the ERP. The two Sub-flow steps come from the Payments webhooks package.",
                "type": "comment",
                "w": 760,
                "x": 40,
                "y": 0
              },
              {
                "id": "src",
                "label": "Payment webhook",
                "match": "webhook:{{HOOK}}",
                "type": "source",
                "webhook": {
                  "capture_headers": [
                    "x-delivery-id"
                  ],
                  "respond": "on_done",
                  "signature": {
                    "header": "x-signature",
                    "prefix": "sha256=",
                    "scheme": "hmac_sha256",
                    "secret": "{{WEBHOOK_SECRET}}",
                    "timestamp_header": "x-timestamp",
                    "tolerance_secs": 300
                  },
                  "wait_ms": 20000
                },
                "x": 40,
                "y": 120
              },
              {
                "calls": "pkg:com.opexcg.payments-webhooks/deliver-once",
                "id": "once",
                "label": "Once per delivery",
                "type": "subflow",
                "x": 300,
                "y": 120
              },
              {
                "calls": "pkg:com.opexcg.payments-webhooks/normalize-payment",
                "id": "normalize",
                "label": "Normalise and validate",
                "type": "subflow",
                "x": 560,
                "y": 120
              },
              {
                "body": "{\"payment_id\":\"${payment_id}\",\"order_id\":\"${order_id}\",\"amount\":${amount},\"currency\":\"${currency}\",\"paid_at\":\"${paid_at}\"}",
                "headers": {
                  "Content-Type": "application/json"
                },
                "id": "erp",
                "label": "Post to the ERP",
                "merge": "merge",
                "method": "POST",
                "openapi": "com.opexcg.payments-webhooks.erp-payments#postPayment",
                "type": "http",
                "url": "{{ERP_URL}}/payments",
                "x": 820,
                "y": 120
              }
            ]
          },
          "id": "payments-webhook-erp",
          "inputs": [
            {
              "default": "payments",
              "help": "The sender calls POST /api/hooks/<name>.",
              "key": "HOOK",
              "kind": "text",
              "label": "Webhook name",
              "pattern": "^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$"
            },
            {
              "default": "payments_webhook_secret",
              "help": "Name of the secret holding the shared key. It must exist before you deploy.",
              "key": "WEBHOOK_SECRET",
              "kind": "secret",
              "label": "Signing secret"
            },
            {
              "default": "https://erp.example.com",
              "help": "Payments are posted to <url>/payments. Not used while the mock is on.",
              "key": "ERP_URL",
              "kind": "text",
              "label": "ERP base URL",
              "real_only": true
            }
          ],
          "mock": {
            "nodes": [
              "erp"
            ],
            "service": "pw-erp",
            "services": {
              "pw-erp": {
                "description": "An ERP that books a payment and answers with an id, as the erp-payments contract says.",
                "endpoints": [
                  {
                    "method": "POST",
                    "path": "/payments",
                    "responses": [
                      {
                        "body": {
                          "id": "ERP-${call}",
                          "order_id": "${request.body.order_id}",
                          "status": "booked"
                        },
                        "status": 201
                      }
                    ]
                  }
                ]
              }
            }
          },
          "needs": {
            "config": "transform:\n  executor:\n    http_host_allowlist: [\"erp.example.com\"]   # only with the mock switched off",
            "env": [
              "secret `{{WEBHOOK_SECRET_NAME}}`: the shared HMAC key (Secrets, admin)"
            ],
            "mocks": [
              "pw-erp"
            ],
            "sources": [
              "webhook:{{HOOK}}: POST /api/hooks/{{HOOK}} with headers x-signature: sha256=<hmac>, x-timestamp and x-delivery-id"
            ]
          },
          "purpose": "A signed payment callback in any common gateway shape, processed once, normalised, validated and posted to the ERP.",
          "runs": {
            "mode": "ready",
            "note": "The ERP is the bundled mock service pw-erp. Create the signing secret first (Secrets, admin), then sign a body as shown in the docs and POST it to /api/hooks/<name>. Test runs the sample Midtrans-style payment."
          },
          "title": "Payment webhook to ERP (package sub-flows)",
          "try": {
            "node": "src",
            "record": {
              "_hook": {
                "headers": {
                  "x-delivery-id": "d-2001"
                },
                "name": "{{HOOK}}",
                "received_at": "2026-10-30T02:31:12Z"
              },
              "gross_amount": "480000.00",
              "merchant_id": "OUTLET-1",
              "order_id": "INV-2026-000123",
              "payment_type": "qris",
              "settlement_time": "2026-10-30 09:31:12",
              "transaction_id": "T-20261030-0001"
            }
          }
        }
      ],
      "openapi": [
        {
          "id": "erp-payments",
          "spec": {
            "info": {
              "description": "The contract an ERP honours when opex-flow posts a confirmed payment. Use it on an http node as openapi: com.opexcg.payments-webhooks.erp-payments#postPayment: the request body and the 2xx response are checked against it.",
              "title": "ERP payment posting",
              "version": "1.0.0"
            },
            "openapi": "3.0.3",
            "paths": {
              "/payments": {
                "post": {
                  "operationId": "postPayment",
                  "requestBody": {
                    "content": {
                      "application/json": {
                        "schema": {
                          "properties": {
                            "amount": {
                              "minimum": 0,
                              "type": "number"
                            },
                            "currency": {
                              "maxLength": 3,
                              "minLength": 3,
                              "type": "string"
                            },
                            "order_id": {
                              "minLength": 1,
                              "type": "string"
                            },
                            "paid_at": {
                              "format": "date-time",
                              "type": "string"
                            },
                            "payment_id": {
                              "minLength": 1,
                              "type": "string"
                            }
                          },
                          "required": [
                            "payment_id",
                            "order_id",
                            "amount",
                            "currency",
                            "paid_at"
                          ],
                          "type": "object"
                        }
                      }
                    },
                    "required": true
                  },
                  "responses": {
                    "201": {
                      "content": {
                        "application/json": {
                          "schema": {
                            "properties": {
                              "id": {
                                "minLength": 1,
                                "type": "string"
                              },
                              "order_id": {
                                "type": "string"
                              },
                              "status": {
                                "enum": [
                                  "booked",
                                  "duplicate"
                                ],
                                "type": "string"
                              }
                            },
                            "required": [
                              "id",
                              "order_id",
                              "status"
                            ],
                            "type": "object"
                          }
                        }
                      },
                      "description": "Booked"
                    },
                    "422": {
                      "content": {
                        "application/json": {
                          "schema": {
                            "properties": {
                              "error": {
                                "type": "string"
                              }
                            },
                            "required": [
                              "error"
                            ],
                            "type": "object"
                          }
                        }
                      },
                      "description": "Refused"
                    }
                  },
                  "summary": "Book a confirmed payment"
                }
              }
            }
          },
          "title": "ERP payment posting"
        }
      ],
      "subflows": [
        {
          "description": "Drops a redelivered webhook. Keyed by the x-delivery-id header, which the signed webhook source must capture (webhook.capture_headers: [\"x-delivery-id\"]); a delivery without the header fails. Remembers a delivery for 7 days.",
          "edges": [
            {
              "from": "in",
              "id": "in-once",
              "to": "once"
            },
            {
              "from": "once",
              "id": "once-out",
              "to": "out"
            }
          ],
          "id": "deliver-once",
          "nodes": [
            {
              "id": "in",
              "type": "subflow_in",
              "x": 40,
              "y": 80
            },
            {
              "id": "once",
              "key": "${_hook.headers.x-delivery-id}",
              "label": "Once per delivery",
              "on_missing": "fail",
              "scope": "payments-webhook",
              "ttl": "7d",
              "type": "dedupe",
              "x": 300,
              "y": 80
            },
            {
              "id": "out",
              "type": "subflow_out",
              "x": 560,
              "y": 80
            }
          ],
          "title": "Once per delivery"
        },
        {
          "description": "Turns a gateway callback into the payment.confirmed event data and checks it against that event type; an invalid payment is quarantined with the reasons. Reads the usual field names of Midtrans, Xendit and DOKU-style payloads. A timestamp without a UTC offset is read as Asia/Jakarta (+07:00). The record keeps its _hook metadata.",
          "edges": [
            {
              "from": "in",
              "id": "in-normalize",
              "to": "normalize"
            },
            {
              "from": "normalize",
              "id": "normalize-valid",
              "to": "valid"
            },
            {
              "from": "valid",
              "id": "valid-out",
              "to": "out"
            }
          ],
          "id": "normalize-payment",
          "nodes": [
            {
              "id": "in",
              "type": "subflow_in",
              "x": 40,
              "y": 80
            },
            {
              "backend": "js",
              "id": "normalize",
              "label": "Gateway payload -> payment",
              "on_error": "fail",
              "script": "// Gateway callback -> the data of a payment.confirmed event.\n// Field names it looks for (first one present wins):\n//   payment_id   payment_id | transaction_id | id\n//   order_id     order_id | external_id | reference_id | invoice_number\n//   amount       amount | gross_amount | paid_amount        (number or numeric text, must be > 0)\n//   currency     currency                                    (default IDR)\n//   method       method | payment_type | payment_method      (see METHODS)\n//   paid_at      paid_at | settlement_time | transaction_time | paid\n//   outlet_code  outlet_code | merchant_id | store_id | outlet\n//   gateway      gateway;  gateway_reference  gateway_reference | reference | approval_code\n// A timestamp without a UTC offset (\"2026-10-30 09:31:12\") is read as Asia/Jakarta, +07:00.\nconst METHODS = {\n  card: \"card\", credit_card: \"card\", debit_card: \"card\", cc: \"card\",\n  qris: \"qris\", gopay_qris: \"qris\",\n  virtual_account: \"virtual_account\", va: \"virtual_account\", echannel: \"virtual_account\", permata: \"virtual_account\",\n  ewallet: \"ewallet\", gopay: \"ewallet\", shopeepay: \"ewallet\", ovo: \"ewallet\", dana: \"ewallet\", linkaja: \"ewallet\",\n  bank_transfer: \"bank_transfer\", transfer: \"bank_transfer\",\n  cash: \"cash\",\n};\n\nfunction pick(r, names) {\n  for (const n of names) {\n    const v = r[n];\n    if (v !== undefined && v !== null && String(v).trim() !== \"\") return v;\n  }\n  return undefined;\n}\n\nfunction need(r, what, names) {\n  const v = pick(r, names);\n  if (v === undefined) throw new Error(`${what} is missing: looked for ${names.join(\", \")}`);\n  return v;\n}\n\nfunction isoTime(v) {\n  const s = String(v).trim();\n  if (/^\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}(:\\d{2}(\\.\\d+)?)?(Z|[+-]\\d{2}:?\\d{2})$/i.test(s)) {\n    return s.replace(\" \", \"T\").replace(/([+-]\\d{2})(\\d{2})$/, \"$1:$2\");\n  }\n  const m = /^(\\d{4}-\\d{2}-\\d{2})[T ](\\d{2}:\\d{2})(:\\d{2})?$/.exec(s);\n  if (m) return `${m[1]}T${m[2]}${m[3] || \":00\"}+07:00`;\n  throw new Error(`paid_at '${s}' is not a date-time (YYYY-MM-DD HH:MM:SS, optionally with a UTC offset)`);\n}\n\nfunction transform(r) {\n  const rawAmount = need(r, \"amount\", [\"amount\", \"gross_amount\", \"paid_amount\"]);\n  const amount = Number(rawAmount);\n  if (!Number.isFinite(amount) || amount <= 0) throw new Error(`amount must be a number greater than 0, got ${JSON.stringify(rawAmount)}`);\n  const rawMethod = String(need(r, \"method\", [\"method\", \"payment_type\", \"payment_method\"])).trim().toLowerCase();\n  const method = METHODS[rawMethod] || (/_va$/.test(rawMethod) ? \"virtual_account\" : undefined);\n  if (!method) throw new Error(`unknown payment method '${rawMethod}'`);\n  const out = {\n    payment_id: String(need(r, \"payment_id\", [\"payment_id\", \"transaction_id\", \"id\"])),\n    order_id: String(need(r, \"order_id\", [\"order_id\", \"external_id\", \"reference_id\", \"invoice_number\"])),\n    amount,\n    currency: String(pick(r, [\"currency\"]) || \"IDR\").toUpperCase(),\n    method,\n    paid_at: isoTime(need(r, \"paid_at\", [\"paid_at\", \"settlement_time\", \"transaction_time\", \"paid\"])),\n    outlet_code: String(need(r, \"outlet_code\", [\"outlet_code\", \"merchant_id\", \"store_id\", \"outlet\"])),\n  };\n  const gateway = pick(r, [\"gateway\"]);\n  if (gateway !== undefined) out.gateway = String(gateway);\n  const reference = pick(r, [\"gateway_reference\", \"reference\", \"approval_code\"]);\n  if (reference !== undefined) out.gateway_reference = String(reference);\n  if (r._hook) out._hook = r._hook;\n  return out;\n}\n",
              "type": "transform",
              "x": 300,
              "y": 80
            },
            {
              "id": "valid",
              "label": "Must be payment.confirmed",
              "on_invalid": "quarantine",
              "schema": "payment.confirmed",
              "type": "validate",
              "x": 560,
              "y": 80
            },
            {
              "id": "out",
              "type": "subflow_out",
              "x": 820,
              "y": 80
            }
          ],
          "title": "Normalise and validate a payment"
        }
      ]
    },
    "manifest": {
      "author": "OpexCG",
      "description": "Receive payment-gateway callbacks safely: each delivery is processed once, any common gateway payload (Midtrans, Xendit, DOKU style or already normalised) becomes a validated payment.confirmed event, and it is posted to an ERP against a vendored OpenAPI contract. Ships a ready-to-run example with a mock ERP.",
      "homepage": "https://flow.opexcg.com/packages",
      "license": "Free for use with opex-flow",
      "name": "com.opexcg.payments-webhooks",
      "requires": "2.1.0",
      "title": "Payments webhooks",
      "version": "1.0.0"
    }
  },
  "signature": "g30X6uP6Z8FlUyfTED6T9khcUd5CFPDN3dUCOKQfievWqrolb3qfjvxWYB6WvkrCDVc1n44KS09NO0HRU+2GBg==",
  "key_id": "opexcg-1"
}
