WIP: Online Payments #653

Draft
tobru wants to merge 47 commits from design/online-payments into main
Owner

Relates to #634

Relates to #634
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tobru force-pushed design/online-payments from 98edc1020a to c4fcae8031
All checks were successful
Tests / test (push) Successful in 2m13s
2026-09-05 11:15:48 +00:00
Compare
Author
Owner

Payment provider evaluation: Payrexx vs wallee

Recommendation: wallee. It wins on the one API capability the design's safety model depends on, and it is cheaper at every volume we are likely to see. The cost advantage is contingent on the acquiring negotiation rather than published, which is the main thing to verify before committing.

Constraints that narrowed the field

  1. Swiss entity, Swiss processing is a hard requirement. That rules out Stripe, Adyen, Mollie and Braintree, leaving Payrexx (Thun), wallee (Winterthur), Datatrans (Zurich), Saferpay (Worldline Schweiz) and Nexi Switzerland.
  2. The provider must bring acquiring (all-in-one, one contract, one payout). That rules out Datatrans and Saferpay, which are pure gateways on top of a separately negotiated acquirer. Remaining: Payrexx and wallee, plus PostFinance Checkout, which is wallee white-labelled.

What the implementation requires from a provider

The PaymentProvider ABC in src/servala/core/payments/base.py demands:

  • hosted redirect registration with zero-amount 3-D Secure, returning a reusable token
  • merchant-initiated charges with that token, arbitrary amount and currency
  • find_transaction(reference): lookup by our own merchant reference
  • delete_token()
  • polling only, no webhooks

API comparison

The decisive requirement: find_transaction(reference)

The double-charge safety model rests on this. After a crash we may hold no provider transaction id, only our own invoice-<id>-attempt-<n> reference, and we must ask the provider "do you already know this?" before charging again.

wallee exposes merchantReference as a first-class filterable field via POST /transaction/search with an EntityQueryFilter leaf (EQUALS, fieldName: merchantReference). An exact, indexed lookup, which is precisely the call the ABC wants.

Payrexx's list-transactions endpoint accepts only filterDatetimeUtcGreaterThan / filterDatetimeUtcLessThan, filterMyTransactionsOnly, orderByTime, offset and limit. There is no referenceId filter. find_transaction would become a paged date-window scan with client-side matching, on exactly the recovery path that three adversarial review rounds existed to harden. That alone is close to disqualifying.

Feature matrix

wallee Payrexx
Lookup by our reference POST /transaction/search, merchantReference filterable Not supported; date-window scan only
Token object Token / TokenVersion, TokenService create and delete Tokenization is the pre-auth transaction itself; only update documented, no delete
MIT semantics Explicit: customersPresence = NOT_PRESENT + processWithoutUserInteraction Implicit: "charge a pre-authorized transaction", no documented CIT/MIT stored-credential flag
Arbitrary charge amount Yes Yes ("higher or lower than the one set in step 1")
Python SDK Official (wallee-payment/python-sdk) None official; plain REST
Idempotency Native client-generated nonce; replays return the original result Only our own referenceId, which cannot be queried

Open technical questions

  1. Zero-amount 3DS registration is unconfirmed for both. Payrexx requires amount on the gateway and never documents CHF 0. wallee's tokenCreationMode = FORCE_CREATION with a zero line item is connector-dependent and may silently become a small authorization plus void. This is a design-level assumption and needs confirming with the provider and the acquirer before anything else. If it does not hold, the registration flow changes (CHF 1 auth plus reversal).
  2. wallee does not enforce merchantReference uniqueness. Our find_transaction must sort createdOn DESC and handle multiple hits rather than assume one.
  3. Payrexx terminology quirk: preAuthorization=1 means tokenization, while a true authorization hold is reservation=1. Easy to get wrong.

Cost comparison

The two price on completely different structures, which matters more than the headline rates.

Payrexx bundles acquiring into one all-in percentage, committed up front.

Plan Monthly Visa/MC online
Free CHF 0 2.50% + CHF 0.30
Standard ~EUR 15 (2025 listing; billed annually, 1-3 year term) 1.65% + CHF 0.18
Premium ~EUR 39 (same caveat) 1.35% + CHF 0.18
Custom negotiated from EUR 100k/month volume

Other Payrexx rates: TWINT 1.25-1.30%, bank transfer 0.50-0.60%, QR-invoice via Payrexx Pay 0.50% (Standard/Premium). Discounts: 50% off subscription for non-profits, 30% for startups under 5 years.

wallee never charges a percentage. It charges a flat fee per authorized transaction; the percentage comes from the acquirer (Concardis/Nexi) under a separate contract.

Plan Monthly Per transaction Acquiring
Basic CHF 19.95 CHF 0.20 (refunds CHF 0.20) ~0.9% + CHF 0.20, indicative only
Business CHF 69.90 CHF 0.12 negotiated
Enterprise custom custom custom

Plus a one-off CHF 95 payment-processor configuration, and optional Advanced Support at CHF 74/month. Notably no fee for failed charges, which matters directly for our three-attempt dunning ladder.

Worked comparison

Effective blended rates: Payrexx Standard 1.65% + 0.18, Payrexx Premium 1.35% + 0.18, wallee Basic ~0.9% + 0.40 (CHF 0.20 wallee + CHF 0.20 acquirer).

Monthly volume / invoices Payrexx Standard Payrexx Premium wallee Basic
CHF 2,000 / 20 CHF 56 CHF 72 CHF 46
CHF 10,000 / 50 CHF 193 CHF 189 CHF 130
CHF 100,000 / 200 CHF 1,705 CHF 1,431 CHF 1,000

wallee wins at every scale, and the gap widens with volume, roughly CHF 430/month cheaper than Payrexx Premium at CHF 100k. The driver is entirely the 0.75-point spread between 1.65% and ~0.9%.

Three caveats that could flip the cost picture

  1. The ~0.9% is indicative, not contractual. wallee publishes it as an illustration; the real rate lands in the Concardis/Nexi contract after risk and volume assessment. Payrexx's 1.65% is a committed price. This is certainty versus negotiable upside, and wallee only wins if we actually negotiate.
  2. We are B2B, so expect commercial cards. Corporate and purchasing cards carry materially higher interchange than consumer cards, and non-EEA cards higher still. Whether each provider absorbs that in the blended rate or surcharges it is the biggest unknown in the table. Ask both directly: what do we pay on a Swiss corporate Visa, and on a US-issued commercial card?
  3. Card rails are expensive for large invoices either way. At CHF 100k/month we burn CHF 1,000-1,700 monthly on ~200 charges. Payrexx QR-invoice is 0.50% and bank transfer 0.50-0.60%. If invoice sizes are large, the real saving is routing big accounts to QR-bill and reserving card-on-file for small ones.

Open questions before committing

  • Confirm zero-amount (CHF 0) 3DS card verification is supported by the provider and the acquirer. Affects the design, not just the adapter.
  • Get a written acquiring quote from wallee, including the rate for Swiss corporate cards and for non-EEA commercial cards.
  • Same question to Payrexx: is 1.65% / 1.35% truly all-in for commercial and non-EEA cards, or are there surcharges?
  • Payrexx contract term: confirm whether 1-3 year annual billing is mandatory.
  • Decide whether PostFinance Checkout (wallee technology, PostFinance brand and acquiring) is worth evaluating as a variant.
  • Confirm multi-currency handling if we charge EUR invoices through a CHF acquirer (FX markup, settlement currency).

Sources

# Payment provider evaluation: Payrexx vs wallee **Recommendation: wallee.** It wins on the one API capability the design's safety model depends on, and it is cheaper at every volume we are likely to see. The cost advantage is contingent on the acquiring negotiation rather than published, which is the main thing to verify before committing. ## Constraints that narrowed the field 1. **Swiss entity, Swiss processing** is a hard requirement. That rules out Stripe, Adyen, Mollie and Braintree, leaving Payrexx (Thun), wallee (Winterthur), Datatrans (Zurich), Saferpay (Worldline Schweiz) and Nexi Switzerland. 2. **The provider must bring acquiring** (all-in-one, one contract, one payout). That rules out Datatrans and Saferpay, which are pure gateways on top of a separately negotiated acquirer. Remaining: Payrexx and wallee, plus PostFinance Checkout, which is wallee white-labelled. ## What the implementation requires from a provider The `PaymentProvider` ABC in `src/servala/core/payments/base.py` demands: - hosted redirect registration with **zero-amount 3-D Secure**, returning a reusable token - **merchant-initiated** charges with that token, arbitrary amount and currency - `find_transaction(reference)`: **lookup by our own merchant reference** - `delete_token()` - polling only, no webhooks ## API comparison ### The decisive requirement: `find_transaction(reference)` The double-charge safety model rests on this. After a crash we may hold no provider transaction id, only our own `invoice-<id>-attempt-<n>` reference, and we must ask the provider "do you already know this?" before charging again. **wallee** exposes `merchantReference` as a first-class filterable field via `POST /transaction/search` with an `EntityQueryFilter` leaf (`EQUALS`, `fieldName: merchantReference`). An exact, indexed lookup, which is precisely the call the ABC wants. **Payrexx's** list-transactions endpoint accepts only `filterDatetimeUtcGreaterThan` / `filterDatetimeUtcLessThan`, `filterMyTransactionsOnly`, `orderByTime`, `offset` and `limit`. There is **no `referenceId` filter**. `find_transaction` would become a paged date-window scan with client-side matching, on exactly the recovery path that three adversarial review rounds existed to harden. That alone is close to disqualifying. ### Feature matrix | | wallee | Payrexx | |---|---|---| | Lookup by our reference | `POST /transaction/search`, `merchantReference` filterable | **Not supported**; date-window scan only | | Token object | `Token` / `TokenVersion`, `TokenService` create **and delete** | Tokenization is the pre-auth transaction itself; only *update* documented, no delete | | MIT semantics | Explicit: `customersPresence = NOT_PRESENT` + `processWithoutUserInteraction` | Implicit: "charge a pre-authorized transaction", no documented CIT/MIT stored-credential flag | | Arbitrary charge amount | Yes | Yes ("higher or lower than the one set in step 1") | | Python SDK | Official (`wallee-payment/python-sdk`) | None official; plain REST | | Idempotency | Native client-generated nonce; replays return the original result | Only our own `referenceId`, which cannot be queried | ### Open technical questions 1. **Zero-amount 3DS registration is unconfirmed for both.** Payrexx requires `amount` on the gateway and never documents CHF 0. wallee's `tokenCreationMode = FORCE_CREATION` with a zero line item is connector-dependent and may silently become a small authorization plus void. This is a design-level assumption and needs confirming with the provider and the acquirer before anything else. If it does not hold, the registration flow changes (CHF 1 auth plus reversal). 2. **wallee does not enforce `merchantReference` uniqueness.** Our `find_transaction` must sort `createdOn DESC` and handle multiple hits rather than assume one. 3. Payrexx terminology quirk: `preAuthorization=1` means *tokenization*, while a true authorization hold is `reservation=1`. Easy to get wrong. ## Cost comparison The two price on completely different structures, which matters more than the headline rates. **Payrexx** bundles acquiring into one all-in percentage, committed up front. | Plan | Monthly | Visa/MC online | |---|---|---| | Free | CHF 0 | 2.50% + CHF 0.30 | | Standard | ~EUR 15 (2025 listing; billed annually, 1-3 year term) | 1.65% + CHF 0.18 | | Premium | ~EUR 39 (same caveat) | 1.35% + CHF 0.18 | | Custom | negotiated | from EUR 100k/month volume | Other Payrexx rates: TWINT 1.25-1.30%, bank transfer 0.50-0.60%, QR-invoice via Payrexx Pay 0.50% (Standard/Premium). Discounts: 50% off subscription for non-profits, 30% for startups under 5 years. **wallee** never charges a percentage. It charges a flat fee per authorized transaction; the percentage comes from the acquirer (Concardis/Nexi) under a separate contract. | Plan | Monthly | Per transaction | Acquiring | |---|---|---|---| | Basic | CHF 19.95 | CHF 0.20 (refunds CHF 0.20) | ~0.9% + CHF 0.20, *indicative only* | | Business | CHF 69.90 | CHF 0.12 | negotiated | | Enterprise | custom | custom | custom | Plus a one-off CHF 95 payment-processor configuration, and optional Advanced Support at CHF 74/month. Notably **no fee for failed charges**, which matters directly for our three-attempt dunning ladder. ### Worked comparison Effective blended rates: Payrexx Standard **1.65% + 0.18**, Payrexx Premium **1.35% + 0.18**, wallee Basic **~0.9% + 0.40** (CHF 0.20 wallee + CHF 0.20 acquirer). | Monthly volume / invoices | Payrexx Standard | Payrexx Premium | wallee Basic | |---|---|---|---| | CHF 2,000 / 20 | CHF 56 | CHF 72 | **CHF 46** | | CHF 10,000 / 50 | CHF 193 | CHF 189 | **CHF 130** | | CHF 100,000 / 200 | CHF 1,705 | CHF 1,431 | **CHF 1,000** | wallee wins at every scale, and the gap widens with volume, roughly CHF 430/month cheaper than Payrexx Premium at CHF 100k. The driver is entirely the 0.75-point spread between 1.65% and ~0.9%. ### Three caveats that could flip the cost picture 1. **The ~0.9% is indicative, not contractual.** wallee publishes it as an illustration; the real rate lands in the Concardis/Nexi contract after risk and volume assessment. Payrexx's 1.65% is a committed price. This is certainty versus negotiable upside, and wallee only wins if we actually negotiate. 2. **We are B2B, so expect commercial cards.** Corporate and purchasing cards carry materially higher interchange than consumer cards, and non-EEA cards higher still. Whether each provider absorbs that in the blended rate or surcharges it is the biggest unknown in the table. Ask both directly: *what do we pay on a Swiss corporate Visa, and on a US-issued commercial card?* 3. **Card rails are expensive for large invoices either way.** At CHF 100k/month we burn CHF 1,000-1,700 monthly on ~200 charges. Payrexx QR-invoice is 0.50% and bank transfer 0.50-0.60%. If invoice sizes are large, the real saving is routing big accounts to QR-bill and reserving card-on-file for small ones. ## Open questions before committing - [ ] Confirm zero-amount (CHF 0) 3DS card verification is supported by the provider **and** the acquirer. Affects the design, not just the adapter. - [ ] Get a written acquiring quote from wallee, including the rate for Swiss corporate cards and for non-EEA commercial cards. - [ ] Same question to Payrexx: is 1.65% / 1.35% truly all-in for commercial and non-EEA cards, or are there surcharges? - [ ] Payrexx contract term: confirm whether 1-3 year annual billing is mandatory. - [ ] Decide whether PostFinance Checkout (wallee technology, PostFinance brand and acquiring) is worth evaluating as a variant. - [ ] Confirm multi-currency handling if we charge EUR invoices through a CHF acquirer (FX markup, settlement currency). ## Sources - Payrexx pricing: https://payrexx.com/en-ch/pricing/ - Payrexx pricing (OMR review, plan fees): https://omr.com/en/reviews/product/payrexx/pricing - Payrexx retrieve-transactions: https://developers.payrexx.com/reference/retrieve-transactions - Payrexx tokenization: https://developers.payrexx.com/docs/tokenization - Payrexx pre-authorization: https://developers.payrexx.com/docs/preauthorization - Payrexx transaction model: https://developers.payrexx.com/docs/transaction - wallee pricing: https://app-wallee.com/en/pricing/other - wallee pricing (CH): https://app-wallee.com/pricing/ch - wallee tokenization: https://app-wallee.com/en/doc/payment/tokenization - wallee Token model: https://app-wallee.com/en/doc/api/model/token - wallee recurring payments: https://en.wallee.com/accept-payments/recurring-payments - wallee Python SDK: https://github.com/wallee-payment/python-sdk - wallee startup promotion: https://en.wallee.com/promo/startups
All checks were successful
Tests / test (push) Successful in 2m13s
This pull request has changes conflicting with the target branch.
  • src/servala/core/notifications.py
  • src/servala/frontend/templates/frontend/base.html
  • src/servala/frontend/templates/frontend/organizations/invoices.html
  • src/servala/frontend/templates/frontend/organizations/service_detail.html
  • src/servala/frontend/templates/frontend/organizations/services.html
  • src/servala/frontend/views/organization.py
  • src/servala/settings.py
  • src/tests/core/test_audit_stamping.py
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin design/online-payments:design/online-payments
git switch design/online-payments

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff design/online-payments
git switch design/online-payments
git rebase main
git switch main
git merge --ff-only design/online-payments
git switch design/online-payments
git rebase main
git switch main
git merge --no-ff design/online-payments
git switch main
git merge --squash design/online-payments
git switch main
git merge --ff-only design/online-payments
git switch main
git merge design/online-payments
git push origin main
Sign in to join this conversation.
No description provided.