Skip to content

feat(payment): add Creem payment provider - #1

Closed
mr-kelly wants to merge 1 commit into
mainfrom
feat/creem-payment-provider
Closed

mr-kelly wants to merge 1 commit into
mainfrom
feat/creem-payment-provider

Conversation

@mr-kelly

Copy link
Copy Markdown

Verification level: 🟡 Partially verified — every path below was run end to end against the real Creem test API, with real test-card payments and Creem-signed webhook events. Not covered: subscription-plan purchases via Creem, EUR, discount codes, live keys.

Problem

Sub2API can take payments through Stripe, Airwallex and the Chinese channels, but not Creem. Creem is a Merchant of Record and collects sales tax/VAT for us, which matters for international users.

Fix

  • New creem provider. Top-up amounts vary, so the admin configures one one-time product in Creem. Each order creates a checkout on that product with custom_price set to the order amount (Creem's range is 1.00–999,999.99), and the user is redirected to the Creem hosted checkout. Orders are credited by webhook, or by the existing active order query.
  • Webhooks. Both Standard Webhooks (webhook-id/-timestamp/-signature, 5-minute tolerance) and legacy creem-signature HMAC signatures are verified. The provider handles checkout.completed and ignores everything else with a 200.
  • Refunds are full-amount only, because Creem's POST /v1/refunds takes just a transaction_id. Partial refunds are rejected before calling Creem. The refund response has no id, only status, so the transaction id is used as the refund id. Before refunding, the provider checks whether the transaction is already refunded. This makes retries safe after a timeout or rollback; Creem itself returns 400 "no remaining amount" on a second refund.
  • Product checks at checkout time. The configured product must be active, one-time, use inclusive tax, and match the configured currency (USD/EUR, default USD, never CNY). With exclusive tax the buyer would pay more than the order amount and fail the fulfillment amount check.
  • Late payments are refunded automatically (new behaviour, Creem only). Stripe and Airwallex cancel the payment intent when an order expires. Creem has no API to cancel a checkout, so the link stays payable. Before this change, a payment made more than 5 minutes after expiry (past the grace period) was captured but never credited; it only produced a WARN log. A new optional payment.LatePaymentRefunder interface, implemented only by Creem, now triggers a full refund in that case and writes a LATE_PAYMENT_REFUNDED audit entry. If the refund fails, it writes LATE_PAYMENT_REFUND_FAILED and returns 5xx so Creem retries. Other providers are unchanged.
  • Return URLs on IP hosts are dropped. Creem rejects success_url values whose host is an IP (e.g. http://127.0.0.1:8080) and fails the whole checkout. Those are now omitted; the payment still completes and is credited by webhook.
  • Admin and docs. Provider dialog fields with hints, USD/EUR currency options, payment method button and icon, en/zh i18n, and docs/PAYMENT*.md (setup, webhook, limits).

Verification

Method: local sub2api (Postgres 16 + Redis in throwaway containers) against test-api.creem.io, paying with test card 4111… in a real (headed, Xvfb) browser. For real webhooks, a temporary CLI-delivery Creem webhook was created and its queued events were forwarded unmodified (Creem's own body and creem-signature header) to the local endpoint. The webhook was deleted afterwards.

# Scenario Result
A Pay $10, then the real Creem webhook arrives ✅ order COMPLETED, balance 0 → 10
B Same real event replayed 10× concurrently ✅ all 200, balance stays 10
C Admin full refund, then the real refund.created event ✅ Creem transaction refunded, order REFUNDED, balance deducted; event acknowledged and ignored
— Partial refund ✅ rejected, order and balance unchanged
D1 Pay after expiry, within the 5-minute grace period ✅ order recovered and credited
D2 Pay after expiry, beyond the grace period ❌ before: captured, not credited → ✅ after: refunded in full on Creem, balance unchanged; redelivery does not refund twice
E Amount 0.50 / 1,000,000 ✅ rejected, order marked FAILED (does not block the pending-order limit)
E Test key with live apiBase, currency CNY, missing productId ✅ save rejected
E Real event for an order not in this DB ✅ 200 (stops retries), no credit
E Real event with the amount tampered ✅ 400
F Exclusive-tax product, currency mismatch, wrong API key, unknown product ✅ checkout refused with a specific message
G Credited via active query, then the webhook arrives ✅ no double credit
Regression go test -tags=unit ./..., golangci-lint v2.9 (CI version), vue-tsc, payment vitest suites ✅ pass / 0 issues. 13 frontend tests in unrelated areas (account usage, charts, auth) fail identically on clean main.

The late-payment unit tests fail with the fix removed and pass with it.

Known risks / not covered

  • The Creem webhook was pulled and forwarded, not pushed to a public URL. Creem sent legacy creem-signature headers; the Standard Webhooks path is covered by unit tests only. After deploying, send a test event from the Creem dashboard.
  • Subscription-plan purchases via Creem, EUR, discount codes (an underpaid order would fail the amount check and need manual review), and live keys were not exercised.
  • Late-payment refunds ignore the instance's "allow refund" toggle on purpose: otherwise the user's money is lost.
  • Redelivering a late-payment event writes a second LATE_PAYMENT_REFUNDED audit row. No second refund is made.
  • Pre-existing and not changed here: provider create/update responses return the API key in plaintext; provider config validation errors surface as a generic 500 "internal error" (same for Airwallex).

Screenshots

Story 1: an admin adds Creem

  1. Payment settings, no providers yet
    empty provider list
  2. "Add provider" → Creem shows its own fields and setup hints
    creem fields
  3. Credentials and product ID filled; currency defaults to USD (API key redacted)
    filled
  4. Saved: the Creem instance is listed and enabled
    saved

Story 2: a user tops up $20 with Creem

  1. Top-up page: pick Creem and enter 20
    purchase
  2. "Pay" opens Creem in a new window; Sub2API waits
    waiting
  3. The Creem checkout charges exactly $20.00 (custom_price)
    creem checkout
  4. Test card entered
    test card
  5. Creem redirects back to Sub2API: payment succeeded, order completed
    result
  6. The original tab also flips to success
    original tab
  7. Balance $4.00 → $24.00
    balance

Files Affected

  • backend/internal/payment/provider/creem.go (+ tests): new provider
  • backend/internal/payment/types.go, currency.go, provider/factory.go: TypeCreem, Creem currency rules, LatePaymentRefunder interface
  • backend/internal/service/payment_fulfillment.go (+ payment_late_payment_refund_test.go): automatic refund of late payments
  • backend/internal/service/payment_{config_providers,currency,order,order_provider_snapshot}.go: config, sensitive fields, snapshot validation
  • backend/internal/handler/payment_webhook_handler.go, server/routes/payment.go: POST /api/v1/payment/webhook/creem
  • frontend/src/components/payment/*, views/*, i18n/*, style.css, assets/icons/creem.png: admin and user UI
  • docs/PAYMENT.md, docs/PAYMENT_CN.md

🤖 Generated with Claude Code

Add Creem (Merchant of Record) as a payment provider alongside Stripe and
Airwallex. Balance top-ups create a Creem checkout on a configured one-time
product with custom_price set to the order amount, and redirect the user to
the hosted checkout.

- Provider: checkout creation, order query, webhook verification (Standard
  Webhooks and legacy creem-signature), full refunds with an idempotent
  already-refunded check, product validation (one-time, inclusive tax,
  matching USD/EUR currency).
- Late payments: Creem checkout links cannot be cancelled when an order
  expires, so a payment that arrives after the expiry grace period is now
  refunded automatically via the new payment.LatePaymentRefunder hook instead
  of being left uncredited. Other providers are unaffected.
- Admin settings, payment method selector, i18n (en/zh) and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Thank you for your contribution! Before we can merge this PR, we need you to sign our Contributor License Agreement (CLA).

To sign, please reply with the following comment:

I have read the CLA Document and I hereby sign the CLA

You only need to sign once — it will be valid for all your future contributions to this project.


I have read the CLA Document and I hereby sign the CLA


Kelly Chan seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@mr-kelly

Copy link
Copy Markdown
Author

Superseded: Creem was rebuilt on the latest upstream (feat/creem-payment). It is proposed upstream as Wei-Shaw#8423 and brought into this fork by the new PR. Closing this one, which was based on the pre-sync fork.

@mr-kelly mr-kelly closed this Oct 29, 2026
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.

1 participant