Skip to content
bitzorcas
中EN

Guide

Billing Payment Orders, Callbacks, and Transactions

A detailed guide to provider composition, minor units, anonymous signature verification, idempotent posting, CAP Outbox transactions, acknowledgements, and security gaps.

Last updated

Contracts defines the provider-neutral IPaymentGateway. Infrastructure’s PaymentGatewayAdapter translates it to Bitzsoft.Integrations.Payment.IPaymentProvider. API Host exposes the anonymous callback; JobHost uses the same boundary for renewal orders and status queries.

1. Provider composition

Payment:Provider supports, case-insensitively:

  • alipay with Payment:Alipay;
  • wechatpay with Payment:WeChatPay;
  • stripe with Payment:Stripe.

A missing or unknown value registers UnavailablePaymentGateway and UnavailablePaymentReconciliationPort. Consumers resolve the port but receive Payment.ProviderUnavailable instead of a DI exception.

Select one active provider
{
"Payment": {
"Provider": "stripe",
"Stripe": {
"ApiKey": "inject through a secret store",
"WebhookSecret": "inject through a secret store"
},
"AutoRenewal": {
"Enabled": false,
"CronExpression": "0 0 1 * * ?",
"ExpiryWindowDays": 3
},
"Reconciliation": {
"Enabled": false,
"CronExpression": "0 0 6 ? * MON"
}
}
}

Provider-specific keys belong to the connector package version. PlatformBilling only chooses the section and binds the platform port.

2. Order creation

CreatePaymentOrderRequest contains InvoiceId, IdempotencyKey, ProviderCode, Scene, Subject, TotalAmount, Currency, ReturnUrl, OpenId, AuthCode, and Extra. The adapter requires nonblank invoice/key/provider, positive amount, and CNY/USD/EUR. It multiplies the major-unit decimal by 100, rounds ToEven, and passes the invoice idempotency key as OutTradeNo.

Build an order from an authoritative invoice
public static class PaymentErrors
{
public static readonly Error InvoiceNotPayable =
Error.Conflict("Payment.Invoice.NotPayable", "The invoice is not payable.");
}
// Load tenant-owned server state; never copy amount, currency, or key from the browser.
// Only an Issued invoice is payable through this example flow.
var invoice = await repository.FindInvoiceAsync(
currentUser.User.TenantId,
invoiceId,
cancellationToken);
if (invoice.IsFailure || invoice.Value!.Status != InvoiceStatus.Issued)
return PaymentErrors.InvoiceNotPayable;
var order = await gateway.CreatePaymentOrderAsync(
new CreatePaymentOrderRequest
{
InvoiceId = invoice.Value.InvoiceId,
IdempotencyKey = invoice.Value.IdempotencyKey,
ProviderCode = configuredProvider,
Scene = PaymentSceneKind.Web,
Subject = $"Platform subscription {invoice.Value.Period}",
TotalAmount = invoice.Value.Amount, // Never trust a client-supplied amount.
Currency = invoice.Value.Currency,
ReturnUrl = trustedReturnUrl,
},
cancellationToken);

This is the safe caller pattern. The adapter itself does not reload InvoiceId or prove that amount, currency, and key match that invoice. The repository exposes no payment-order HTTP command. AutoRenewalJobExecutor is the only production caller and discards PayUrl, QrCode, and PrepayId.

3. Anonymous callback

Unified callback route
POST /api/payments/callback/stripe HTTP/1.1
Stripe-Signature: t=1724750000,v1=5257a869e7eceeda32abad62f1a986b77e875f930dd4725130ed02f5d2b7e35f
Content-Type: application/json
{ "id": "evt_1Pxy001", "type": "payment_intent.succeeded" }

The endpoint buffers and reads the original body, all headers, and query string into PaymentCallbackInput. It must not deserialize and reserialize the body before signature verification.

CAP OutboxUnit of WorkBilling repositoryPaymentGatewayAdapterCallback endpointPayment providerCAP OutboxUnit of WorkBilling repositoryPaymentGatewayAdapterCallback endpointPayment providerraw body + signaturePaymentCallbackInputVerifyCallbackAsyncstatus + outTradeNo + paidAmountinvoice by idempotency keyalready Paid? amount matches?BeginMarkPaid + SaveInvoiceInvoicePaidIntegrationEventCommitResultprovider acknowledgement

4. Posting steps

  1. The configured _provider verifies the payload; exceptions and failures become SignatureInvalid.
  2. The verified result must include OutTradeNo.
  3. Only PaymentStatus.Success proceeds.
  4. OutTradeNo locates the invoice by idempotency key.
  5. An already-Paid invoice returns success without another event.
  6. PaidAmount is divided by 100 and compared exactly with invoice Amount.
  7. Only Issued/Overdue can become Paid.
  8. The adapter explicitly begins IUnitOfWork.
  9. It saves the invoice and publishes InvoicePaidIntegrationEvent.
  10. It commits both; exceptions trigger rollback and are rethrown.

The event carries invoice, tenant, OutTradeNo (in the PaymentOrderId field), amount, paid time, provider, and idempotency key. Its runtime topic is the fully qualified event type.

5. Provider acknowledgements

Route providerSuccess acknowledgement
alipayHTTP 200 text/plain success
wechatpayHTTP 200 JSON {code:"SUCCESS",message:"\u6210\u529f"}
stripe/otherHTTP 200 empty body

SignatureInvalid and AmountMismatch are classified as hard failures and still receive a success acknowledgement to stop retries. Other failures return 400.

That policy needs explicit risk approval. Signature failure can also mean key rotation, clock skew, parser defects, or configuration drift. A 2xx can permanently discard a real payment notification. Persist redacted evidence, alert by reason, choose 2xx/4xx/5xx per provider contract, and rely on provider-query reconciliation for compensation.

6. Provider-route mismatch

The {provider} path value only populates PaymentCallbackInput.ProviderCode and chooses the response format. PaymentGatewayAdapter always uses the single DI _provider and never checks:

route provider == configured provider == verified provider.Code

With Stripe configured, a Stripe-verifiable payload sent to /callback/alipay is verified by Stripe but acknowledged as Alipay. Reject mismatches before reading/processing the body, and return a stable unknown-provider error. The route parameter must not control only presentation.

7. Amount and identity boundaries

Order creation supports only two-decimal CNY/USD/EUR. JPY-like zero-decimal and KWD-like three-decimal currencies are rejected. ToEven rounding means 1.005 may become 100 minor units; normalize invoice money to the currency exponent before ordering.

The callback compares amount only. The platform layer does not compare callback currency, provider merchant/account, InvoiceId, TenantId, subject, a known PaymentAttempt, or provider transaction ID. OutTradeNo is the sole relation.

A production PaymentAttempt should retain attempt/key, invoice/tenant, provider/merchant, OutTradeNo/provider trade number, amount/currency, lifecycle status, failure code, created/paid times, callback event ID, and payload hash.

8. Concurrent callbacks

“Already Paid returns success” suppresses sequential duplicates. Two callbacks can both read Issued, both MarkPaid, and both attempt save/event publication. There is no callback inbox, distributed lock, or conditional status update. Whether optimistic Version rejects one depends on the ORM/UoW path, and no test proves one InvoicePaid effect.

Use a unique (Provider,CallbackEventId) inbox plus UPDATE ... WHERE Status IN (Issued,Overdue) in the same transaction as payment fact and Outbox. Test parallel duplicates, differing provider event IDs, commit failures, callbacks arriving before issue commit, Voided/unknown invoices, and provider-query recovery.

9. Transaction scope

The adapter explicitly wraps SaveInvoice and Publish. Atomicity exists only when the production UoW and CAP publisher share the same database transaction. API Shell’s NullUnitOfWork and Null publisher provide startup composition, not a production guarantee.

Rollback failures are logged without masking the original exception. Logs contain invoice/order identifiers and require controlled access and redaction.

10. Missing test evidence

There are no dedicated PaymentGatewayAdapter, PaymentReconciliationAdapter, PaymentEndpointGroup, AutoRenewal, or Reconciliation behavior tests. Required evidence includes three-provider byte fixtures, route/config/provider consistency, minor-unit boundaries, wrong currency/merchant/order/amount, parallel duplicates, database+CAP fault injection, exact response content types, sandbox retry behavior, key rotation, replay windows, and unavailable-provider Host behavior.

Back to Billing · Invoices and idempotency · Jobs, testing, and GA

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%