Phase 3 — ClickPesa integration + PaymentProvider trait #4

Open
opened 2026-05-20 20:46:12 +00:00 by mik-tf · 0 comments
Owner

Tracks s2-005 work for the Hero onboarding parallel track (Agent 2 / Track B). Sub-issue of #1 (meta-issue / full plan).

ClickPesa docs: https://docs.clickpesa.com/

Scope

Introduce a PaymentProvider trait in crates/hero_onboarding_server/src/payment.rs, extract the Phase 2 Stripe code site behind it as StripeProvider, then add ClickPesaProvider as the second impl. The freezone in-house backend (znzfreezone/src/znzfreezone_code/znzfreezone_backend/src/providers/payment.rs, 520 LOC) is production-validated against the live ClickPesa API and lifts cleanly — wire shape, two-step Bearer-token auth, and webhook handling are taken verbatim modulo refactoring ureq blocking → reqwest async to match our axum/tokio stack.

Phase B simplified the trait shape: ClickPesa Checkout Link returns a hosted-URL redirect just like Stripe Checkout, so no TopUpHandoff enum needed; both providers return a plain String redirect URL.

Trait

#[async_trait::async_trait]
pub trait PaymentProvider: Send + Sync {
    fn name(&self) -> &'static str;

    async fn create_top_up(
        &self,
        address: &str,
        user_sid: &str,
        amount_cents: i64,
        public_base_url: &str,
    ) -> anyhow::Result<String>; // hosted-checkout URL

    fn parse_webhook(
        &self,
        headers: &HeaderMap,
        body: &[u8],
        now: i64,
    ) -> Result<WebhookEvent, WebhookError>;
}

Both providers return a plain String redirect URL; the webhook handler in main.rs runs identical dedup-on-external_ref + apply-credit logic regardless of provider.

ClickPesa specifics (from freezone lift)

  • Two-step auth: POST /generate-token with headers client-id + api-key → JWT → Bearer on POST /checkout-link/generate-checkout-url.
  • Body shape: { totalPrice, orderReference, orderCurrency, description, customerEmail, notificationUrl, redirectUrl }. orderReference must be alphanumeric-only (our SIDs are 26-char ULIDs, already alphanumeric).
  • Webhook body shape: { data: { orderReference, status, id } }. Dedup on orderReference; success on status == "success".
  • Secrets via hero_proc store (context onboarding): CLICKPESA_CLIENT_ID, CLICKPESA_API_KEY. Env-var fallback for dev.
  • URLs (env-var configurable for easy launch-day flip): CLICKPESA_API_URL (default https://api.clickpesa.com/third-parties), CLICKPESA_WEBHOOK_URL (the public URL ClickPesa POSTs to; passed as notificationUrl in checkout requests).

Webhook signature stance — freezone-aligned, launch-day TODO

The production freezone impl does no ClickPesa webhook signature verification — it trusts the body shape + dedups on orderReference. User confirms this is production-stable. Lifting verbatim.

This ships a clearly-marked launch-day TODO: a follow-up issue will track enabling signature verification (either ClickPesa's official scheme if/when documented, or an HMAC-over-body MAC against CLICKPESA_WEBHOOK_SIGNING_KEY if not). The env-var placeholder is plumbed through so flipping the verifier on at launch is a config change, not a code change.

Similarly for Stripe: signing key already env-var/secret-store backed; production webhook URL setup in the Stripe dashboard happens at launch, not today.

Routes

  • POST /payment/intent — adds optional provider form/query field; defaults to stripe.
  • POST /webhooks/stripe — existing, unchanged behaviour.
  • POST /webhooks/clickpesa — new (body-trust, dedup on orderReference).

Acceptance gates

  • cargo check --workspace green.
  • cargo test -p hero_onboarding_server green (Stripe sig verifier unit tests preserved; ClickPesa webhook-body parser unit tests added).
  • lab build --release --install --workspace VICTORY.
  • lab infocheck 3/3 clean.
  • scripts/smoke_payments.sh ~20 white-box checks covering both providers' webhook flows.

Decision lock

D-13 — payment-provider-trait — locks the trait shape + ClickPesa freezone-aligned body-trust stance + launch-day signature TODO.

Out of scope

  • Real ClickPesa sandbox round-trip (no test-mode credentials in this workspace yet).
  • Real Stripe / ClickPesa webhook URLs configured in their dashboards (launch-day task).
  • M-Pesa/Tigo/Airtel-direct USSD push (ClickPesa abstracts these behind their Checkout Link).
  • Per-provider refund/dispute handling.
  • Admin-side payment-event audit UI (Phase 5).
Tracks s2-005 work for the Hero onboarding parallel track (Agent 2 / Track B). Sub-issue of https://forge.ourworld.tf/lhumina_code/hero_onboarding/issues/1 (meta-issue / full plan). ClickPesa docs: https://docs.clickpesa.com/ ## Scope Introduce a `PaymentProvider` trait in `crates/hero_onboarding_server/src/payment.rs`, extract the Phase 2 Stripe code site behind it as `StripeProvider`, then add `ClickPesaProvider` as the second impl. The freezone in-house backend (`znzfreezone/src/znzfreezone_code/znzfreezone_backend/src/providers/payment.rs`, 520 LOC) is production-validated against the live ClickPesa API and lifts cleanly — wire shape, two-step Bearer-token auth, and webhook handling are taken verbatim modulo refactoring `ureq` blocking → `reqwest` async to match our axum/tokio stack. Phase B simplified the trait shape: ClickPesa Checkout Link returns a hosted-URL redirect just like Stripe Checkout, so no `TopUpHandoff` enum needed; both providers return a plain `String` redirect URL. ## Trait ```rust #[async_trait::async_trait] pub trait PaymentProvider: Send + Sync { fn name(&self) -> &'static str; async fn create_top_up( &self, address: &str, user_sid: &str, amount_cents: i64, public_base_url: &str, ) -> anyhow::Result<String>; // hosted-checkout URL fn parse_webhook( &self, headers: &HeaderMap, body: &[u8], now: i64, ) -> Result<WebhookEvent, WebhookError>; } ``` Both providers return a plain `String` redirect URL; the webhook handler in `main.rs` runs identical dedup-on-`external_ref` + apply-credit logic regardless of provider. ## ClickPesa specifics (from freezone lift) - Two-step auth: `POST /generate-token` with headers `client-id` + `api-key` → JWT → Bearer on `POST /checkout-link/generate-checkout-url`. - Body shape: `{ totalPrice, orderReference, orderCurrency, description, customerEmail, notificationUrl, redirectUrl }`. `orderReference` must be alphanumeric-only (our SIDs are 26-char ULIDs, already alphanumeric). - Webhook body shape: `{ data: { orderReference, status, id } }`. Dedup on `orderReference`; success on `status == "success"`. - Secrets via hero_proc store (context `onboarding`): `CLICKPESA_CLIENT_ID`, `CLICKPESA_API_KEY`. Env-var fallback for dev. - URLs (env-var configurable for easy launch-day flip): `CLICKPESA_API_URL` (default `https://api.clickpesa.com/third-parties`), `CLICKPESA_WEBHOOK_URL` (the public URL ClickPesa POSTs to; passed as `notificationUrl` in checkout requests). ## Webhook signature stance — freezone-aligned, launch-day TODO The production freezone impl does **no** ClickPesa webhook signature verification — it trusts the body shape + dedups on `orderReference`. User confirms this is production-stable. Lifting verbatim. This ships a clearly-marked launch-day TODO: a follow-up issue will track enabling signature verification (either ClickPesa's official scheme if/when documented, or an HMAC-over-body MAC against `CLICKPESA_WEBHOOK_SIGNING_KEY` if not). The env-var placeholder is plumbed through so flipping the verifier on at launch is a config change, not a code change. Similarly for Stripe: signing key already env-var/secret-store backed; production webhook URL setup in the Stripe dashboard happens at launch, not today. ## Routes - `POST /payment/intent` — adds optional `provider` form/query field; defaults to `stripe`. - `POST /webhooks/stripe` — existing, unchanged behaviour. - `POST /webhooks/clickpesa` — new (body-trust, dedup on `orderReference`). ## Acceptance gates - `cargo check --workspace` green. - `cargo test -p hero_onboarding_server` green (Stripe sig verifier unit tests preserved; ClickPesa webhook-body parser unit tests added). - `lab build --release --install --workspace` VICTORY. - `lab infocheck` 3/3 clean. - `scripts/smoke_payments.sh` ~20 white-box checks covering both providers' webhook flows. ## Decision lock D-13 — payment-provider-trait — locks the trait shape + ClickPesa freezone-aligned body-trust stance + launch-day signature TODO. ## Out of scope - Real ClickPesa sandbox round-trip (no test-mode credentials in this workspace yet). - Real Stripe / ClickPesa webhook URLs configured in their dashboards (launch-day task). - M-Pesa/Tigo/Airtel-direct USSD push (ClickPesa abstracts these behind their Checkout Link). - Per-provider refund/dispute handling. - Admin-side payment-event audit UI (Phase 5).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_onboarding#4
No description provided.