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:
alipaywithPayment:Alipay;wechatpaywithPayment:WeChatPay;stripewithPayment:Stripe.
A missing or unknown value registers UnavailablePaymentGateway and UnavailablePaymentReconciliationPort. Consumers resolve the port but receive Payment.ProviderUnavailable instead of a DI exception.
{ "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.
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
POST /api/payments/callback/stripe HTTP/1.1Stripe-Signature: t=1724750000,v1=5257a869e7eceeda32abad62f1a986b77e875f930dd4725130ed02f5d2b7e35fContent-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.
4. Posting steps
- The configured
_providerverifies the payload; exceptions and failures become SignatureInvalid. - The verified result must include OutTradeNo.
- Only PaymentStatus.Success proceeds.
- OutTradeNo locates the invoice by idempotency key.
- An already-Paid invoice returns success without another event.
- PaidAmount is divided by 100 and compared exactly with invoice Amount.
- Only Issued/Overdue can become Paid.
- The adapter explicitly begins IUnitOfWork.
- It saves the invoice and publishes InvoicePaidIntegrationEvent.
- 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 provider | Success acknowledgement |
|---|---|
| alipay | HTTP 200 text/plain success |
| wechatpay | HTTP 200 JSON {code:"SUCCESS",message:"\u6210\u529f"} |
| stripe/other | HTTP 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.CodeWith 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