Skip to content

adapters/omise: PromptPay push adapter (Thailand) - #2

Open
lipowen wants to merge 1 commit into
mainfrom
feat/omise-promptpay-adapter
Open

lipowen wants to merge 1 commit into
mainfrom
feat/omise-promptpay-adapter

Conversation

@lipowen

@lipowen lipowen commented Jul 10, 2026

Copy link
Copy Markdown

First contrib PSP adapter for the Thailand beachhead: Omise / Opn Payments, scoped to its PromptPay push rail. PromptPay is a rail Omise fronts (not a PSP), so this is modeled PSP-major (adapters/omise, PromptPay as a push chargeMode) per the one-directory-per-PSP rule — a future Thai acquirer for the same rail would be its own adapter dir.

What's here

  • Charge — POST /charges (source[type]=promptpay, form-encoded, Basic auth) → pending + promptpay_qr ChargeResult.Action. Business decline (failed/expired/reversed) → ChargeResult{Status:"failed"} nil error; transport failure (non-2xx / timeout / connection drop) → a returned error, so the scheduler lease-retries instead of consuming a dunning attempt.
  • ParseWebhook — charge.complete → paid/payment_failed, everything else → ignored. Omise webhooks are unsigned, so it fails closed on a supplied signature/secret and the README says loudly to confirm by GET /charges/{id}.
  • capability.json — chargeModes: [push], currencies: {thb: {min: 2000}}. Drift-tested against Capabilities().
  • Vectors (gateway.json / webhooks.json / certify.json) — the language-neutral behavior contract; a future TS twin must replay the same files.

Verified against the live Omise test API (2026-07-09)

Not just mocked — three targeted live probes, each captured in the code/README:

  1. Happy path (live_test.go) — real PromptPay charge → pending + scannable QR at source.scannable_code.image.download_uri (the coded path).
  2. Charge bound — Omise's own invalid_charge 400 gives the real minimum: 2000 satang (฿20), now declared honestly instead of invented.
  3. Idempotency — the observed account (API 2019-05-29) does not dedup on Idempotency-Key; a repeated key minted a second charge. Because PromptPay is push this is not a double-collect (a retry mints a second unpaid QR that expires; reconcile by the paid webhook's charge id) — documented in Charge's doc + README rather than hidden. The key is still sent verbatim so dedup engages on versions that honor it.

Testing

make check green. 37 subtests in adapters/omise:

  • certify battery: CHG-01/03/04, PSH-01, WHK-PAID/IGNORED + derived CUR-REJECT / WHK-CLOSED / CAT-NOOP laws.
  • gateway/webhook vectors: status classification, the paid-triple invariant, type strictness (string/negative amount → error), fail-closed webhook, special-char form-encoding.
  • robustness_test.go: transport failure / connection drop → error, verbatim idempotency key on retry, malformed/empty webhook bodies → error (the cases the declarative harness can't express).

Known follow-ups (tracked in the adapter README)

  • Webhook wire shape live-verification (needs a public endpoint + a completed sandbox charge; the vectors are doc-based today).
  • A GET /charges/{id} confirm-by-lookup helper for the unsigned-webhook path.
  • Optional @monetakit/omise TS twin.

Scope deliberately excludes Omise's card pull rail (would add chargeMode "pull" + Vault) — a separate adapter/PR.

Omise / Opn Payments adapter scoped to the PromptPay push rail: a THB charge
(POST /charges, source[type]=promptpay) returns pending + a promptpay_qr
ChargeResult.Action; settlement arrives via an unsigned charge.complete webhook
that ParseWebhook normalizes (paid/payment_failed/ignored) but fails closed
rather than pretend to authenticate.

Modeled PSP-major (adapters/omise, PromptPay as a push chargeMode) per the
one-directory-per-PSP rule — PromptPay is a rail Omise fronts, not a PSP.

Contract & capabilities:
- capability.json: chargeModes [push] + currencies {thb: min 2000 satang}.
  The ฿20 minimum is sourced from Omise's own invalid_charge error and verified
  against the live test API; no max is documented, so none is declared.
- Business decline (failed/expired/reversed) -> ChargeResult{Status:"failed"}
  nil error; transport failure (non-2xx, timeout, connection drop) -> a returned
  error, so the scheduler lease-retries instead of consuming a dunning attempt.
- Webhook paid events set paymentRef/amount/currency together or refuse to emit.

Idempotency (verified live, documented honestly):
- opts.IdempotencyKey is sent verbatim as Omise's Idempotency-Key header. The
  observed test account (API 2019-05-29) did NOT dedup on it. Because PromptPay
  is push this is not a double-collect — Charge only mints a QR, so a retry
  yields a second UNPAID QR that expires; reconcile by the paid webhook's charge
  id. The key is still sent so dedup engages on versions that honor it.

Testing:
- certify battery green (CHG-01/03/04, PSH-01, WHK-PAID/IGNORED + derived
  CUR-REJECT/WHK-CLOSED/CAT-NOOP laws), driven offline via WithBaseURL.
- gateway + webhook vectors as the language-neutral behavior contract; a future
  TS twin must replay the same files.
- robustness_test.go covers what the declarative harness cannot express:
  transport failure / connection drop, verbatim-key on retry, malformed webhook
  bodies. 37 subtests, make check green.
- Charge wire shape, the THB minimum, and the idempotency behavior all verified
  against the live Omise test API (2026-07-09); env-gated live_test.go pins the
  happy-path run.

Known follow-ups (tracked in the adapter README): webhook wire shape live
verification, a GET /charges/{id} confirm-by-lookup helper, and an optional
@monetakit/omise TS twin.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new contrib-tier Go adapter at adapters/omise for Omise / Opn Payments, scoped to Thailand’s PromptPay push rail, including charge creation (QR issuance) and webhook normalization, plus language-neutral vector fixtures and certification replay.

Changes:

  • Introduces Charge for PromptPay (POST /charges form-encoded, Basic auth) returning pending + promptpay_qr action; maps terminal charge states to succeeded/failed vs. transport errors.
  • Adds ParseWebhook normalization for charge.complete events (unsigned webhooks; fail-closed if signature/secret is supplied).
  • Adds capability declaration + drift test, vector fixtures (gateway.json, webhooks.json, certify.json), and conformance/robustness/live-sandbox tests.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
CHANGELOG.md Announces the new Omise adapter in release notes.
adapters/omise/omise.go Core adapter implementation: capabilities, HTTP plumbing, Charge/Vault, catalog no-ops.
adapters/omise/webhook.go Webhook normalization for charge.complete into neutral WebhookEvent.
adapters/omise/webhook_test.go Replays webhook vectors and asserts fail-closed behavior when signature/secret provided.
adapters/omise/vectors/webhooks.json Language-neutral webhook normalization vectors.
adapters/omise/vectors/gateway.json Language-neutral gateway/charge classification + request-shape vectors.
adapters/omise/vectors/certify.json Fixtures for adapterkit/certify scenario replay.
adapters/omise/robustness_test.go Go-only tests for transport failures, idempotency header passthrough, malformed webhook bodies.
adapters/omise/README.md Adapter docs: scope, flow, idempotency caveat, unsigned webhook warning, live test instructions.
adapters/omise/MAINTAINERS Declares maintainer handle for the adapter.
adapters/omise/live_test.go Opt-in live sandbox probe against Omise test API.
adapters/omise/charge_test.go Replays gateway vectors and validates request shape/basic auth/idempotency header.
adapters/omise/certify_test.go Runs shared certification battery against vectors/certify.json.
adapters/omise/capability.json Declares adapter capabilities (push-only, THB min).
adapters/omise/capability_test.go Drift test ensuring Capabilities() matches capability.json.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread CHANGELOG.md
Comment on lines +19 to +20
battery replay offline. Live-sandbox run and PromptPay charge-bound
declaration still TODO (see the adapter README).
Comment thread adapters/omise/omise.go
Comment on lines +192 to +196
if status < 200 || status >= 300 {
var e omiseError
_ = json.Unmarshal(raw, &e)
return adapterkit.ChargeResult{}, fmt.Errorf("omise: POST /charges -> %d %s: %s", status, e.Code, e.Message)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants