Webority.Payments.Testing
0.17.0
dotnet add package Webority.Payments.Testing --version 0.17.0
NuGet\Install-Package Webority.Payments.Testing -Version 0.17.0
<PackageReference Include="Webority.Payments.Testing" Version="0.17.0" />
<PackageVersion Include="Webority.Payments.Testing" Version="0.17.0" />
<PackageReference Include="Webority.Payments.Testing" />
paket add Webority.Payments.Testing --version 0.17.0
#r "nuget: Webority.Payments.Testing, 0.17.0"
#:package Webority.Payments.Testing@0.17.0
#addin nuget:?package=Webority.Payments.Testing&version=0.17.0
#tool nuget:?package=Webority.Payments.Testing&version=0.17.0
webority-payments
The shared gateway layer of the Webority billing spec (v2 §1.1): provider-neutral payment
contracts plus the Razorpay and Paddle implementations, published as NuGet packages. The domain
layer (plans, subscriptions, invoicing, lifecycle, admin UI, SQL) is deliberately NOT here —
each product owns its copy per the spec (~/.claude/conventions/webority-payments.md).
Upgrading to 0.11.0
- Paddle refuses a per-checkout trial instead of dropping it.
CreateSubscriptionAsyncnow throwsNotSupportedByGatewayExceptionwhen the request carries aTrialEndDateTimeUtc, where 0.10.0 logged a warning and went on to charge the card. Move the trial onto the price withCreatePlanRequest.TrialDays. CheckCapabilities.HasFlag(GatewayCapabilities.TrialOnSubscriptionCreate)before passing a trial end to any adapter; Razorpay sets it, Paddle does not. - Two records gained a trailing optional parameter,
GatewayInvoice.TaxMinorandCreatePlanRequest.TrialDays. Named and positional construction both still compile, but code that positionally deconstructs either record must be recompiled against 0.11.0.
Four money surfaces, one library:
| Surface | Seam | Razorpay implementation |
|---|---|---|
| Recurring billing (subscriptions) | IPaymentGatewayService + IPaymentGatewayFactory |
RazorpayPaymentGatewayService (plans, subscriptions, customers, invoices, refunds, tokens) |
| One-time collection (orders + checkout) | IPaymentCollectionService |
RazorpayPaymentCollectionService (Orders API, payment fetch, checkout-signature verify) |
| Payment links (hosted, shareable page) | IPaymentLinkService |
RazorpayPaymentLinkService (create/get/cancel/notify, partial payments, redirect-signature verify) |
| Payouts (paying partners/vendors — Razorpay X) | IPayoutGatewayService + IPayoutGatewayFactory |
RazorpayPayoutGatewayService (contacts, fund accounts, payouts with idempotency) |
Collection vs links: use IPaymentCollectionService when the payer is in our own UI and we open
checkout against an order. Use IPaymentLinkService when the payer is elsewhere — a link is handed
over out of band (email, SMS, WhatsApp, a printed QR) and paid whenever they get to it, so the
gateway hosts the page and owns notification and reminders.
⚠️ The two sign their callbacks differently. An order/checkout callback signs
order_id|payment_id; a payment-link redirect signspayment_link_id|reference_id|status|payment_id. Each seam exposes its own verify method — verifying a link redirect withVerifyCheckoutSignaturerejects every genuine payment.
| Package | Contents |
|---|---|
Webority.Payments.Abstractions |
The seam interfaces above, IWebhookSignatureVerifier, IWebhookEventMapper, GatewayCapabilities, GatewayEventType (subscription + payment + refund + order + payment-link + payout vocabulary), GatewayWebhookEnvelope, neutral gateway DTOs, PaymentGateway/BillingInterval enums, default GetMaxBillingCycles |
Webority.Payments.Razorpay |
IRazorpayClient/RazorpayClient (thin HttpClient via IHttpClientFactory, no SDK — Webority.Payments.Razorpay.Http), the four adapters, webhook HMAC verifier, webhook event mapper (incl. payout.* / order.paid / payment_link.*), typed RazorpayApiException carrying error.code/error.description, DI: AddRazorpay() / AddRazorpayGateway() / AddRazorpayWebhooks() / AddRazorpayPaymentCollection() / AddRazorpayPaymentLinks() / AddRazorpayPayouts() |
Webority.Payments.Paddle |
IPaddleClient/PaddleClient (thin HttpClient over Paddle Billing v1, no SDK, Webority.Payments.Paddle.Http), PaddlePaymentGatewayService (recurring surface: customers, prices, checkout transactions, subscriptions, transactions-as-invoices, adjustments-as-refunds, saved payment methods), PaddleWebhookSignatureVerifier (Paddle-Signature ts+HMAC with replay window), PaddleWebhookEventMapper, DI: AddPaddle() / AddPaddleGateway() / AddPaddleWebhooks() / UseDefaultPaymentGateway() |
Webority.Payments.Testing |
Razorpay and Paddle webhook fixtures (embedded JSON, reproduced from each provider's documented payloads; a capture from a live account is still owed on the Razorpay side) + three framework-agnostic orchestrator scenario assertions. No EF, no product types, no xunit/nunit dependency. Products call scenarios with their own process delegates |
Layout
Project folders sit flat at the repo root (Webority.Payments.Abstractions/,
Webority.Payments.Razorpay/, Webority.Payments.Testing/, Webority.Payments.Tests/) per the
fleet .NET layout — no src/ or tests/ wrapper. Inside the Razorpay package, Http/ holds the
wire layer (client + wire DTOs + RazorpayApiException); configuration and domain mapping live at
the package root.
Boundary rule: nothing that references a DbContext, a tenancy type, or a product domain entity enters this repo. A helper with a domain dependency belongs in the product.
Consume
Packages are public on nuget.org — zero auth:
dotnet add package Webority.Payments.Razorpay # INR-domestic products
dotnet add package Webority.Payments.Paddle # merchant-of-record, global USD/EUR products
# optional: product billing test suite
dotnet add package Webority.Payments.Testing
Paddle
Paddle is a merchant of record: it is the seller on the customer's invoice, collects the tax, and pays the merchant out. Three consequences shape the adapter, and a product's orchestrator has to know them:
- No server-side subscription create.
CreateSubscriptionAsynccreates a transaction and returns its id asCreateSubscriptionResult.CheckoutReference(plusRedirectUrlwhen the account has a default payment link). The browser opens that transaction with Paddle.js (Paddle.Checkout.open({ transactionId })); Paddle creates the subscription when checkout completes and names it intransaction.completed/subscription.created. TheMetadatapassed to create travels ascustom_dataand comes back onenvelope.Subscription.Metadata, so a product keys it with its own account id to recognise the subscription it never saw created. - The charge event is
transaction.completed, mapped toSubscriptionChargedwhen it names a subscription.Payment.Idis the transaction id (txn_…): it is what refunds key on and the only stable identity, since payment attempts inside a transaction come and go.Invoice.Numberis Paddle's own invoice number, the tax document the customer received. - Refunds are adjustments that await Paddle's approval on a live account, so
ImmediateRefundis not advertised andCreateRefundAsyncreturnsPending; the outcome arrives asadjustment.updated(RefundProcessed/RefundFailed). - Trials are configured on the price, not per checkout. Pass
CreatePlanRequest.TrialDaysand Paddle attaches atrial_periodto the price, so the subscription created from it startstrialing. Paddle therefore does not advertiseTrialOnSubscriptionCreate, and aTrialEndDateTimeUtconCreateSubscriptionAsyncthrowsNotSupportedByGatewayException: honouring it was impossible, and dropping it charged the card while the caller recorded a trial. Razorpay advertises the flag and maps that trial end onto the subscription'sstart_at.
One seller account serves several Webority products. A Paddle account is one legal entity
with one catalog, one customer list and account-wide webhooks: every notification destination
receives every product's events, and a person is one customer across all our products. The
partition is the Paddle product id: each Webority product is one Paddle product, each app
registers its own API key (least permissions), client token and notification destination, and
lists the product ids it sells in Paddle:ProductIds. The mapper then drops transaction and
subscription events for other products before a consumer stores anything; a payload with no
readable product id passes through with a warning (a lost cancellation is worse than a stored
no-op), and a basket mixing our products with another's is dropped with a warning rather than
booked whole. Adjustments carry no product and pass through: the consumer stores the delivery,
fails to resolve the id, and records it as a no-op, so other products' refunds do appear in its
webhook log. The adapter also refuses a checkout whose price belongs to an unlisted product, so a
live-set swap that forgets ProductIds fails at the first Subscribe click instead of dropping
every completion. Scope customer-portal sessions with the subscriptionIds argument so a
customer of two products sees only this one's subscription there (least surprise, not access
control: Paddle's own receipt emails link the unscoped portal).
When swapping sandbox for live, change every value together: ApiKey, WebhookSecret,
ClientToken, Environment AND ProductIds (live product ids differ), plus the price ids
recorded on the product's price rows.
Config section Paddle: ApiKey, WebhookSecret (API head only), ClientToken (the public
Paddle.js token the SPA needs), Environment (sandbox | live, selects the base URL),
ProductIds (the Paddle products this app sells; empty = no filtering),
ProductId (optional catalog product for CreatePlanAsync), WebhookToleranceSeconds (60; a
valid signature outside the window is logged as skew, distinct from a forged one).
Sandbox and live are separate systems: keys, price ids and webhook secrets never cross.
services.AddPaddle(configuration);
services.AddPaddleGateway(); // adapter + webhook verify/map + factory; nominates no default
services.AddPaddleWebhooks(); // API head only — requires WebhookSecret once ApiKey is set
// services.UseDefaultPaymentGateway(PaymentGateway.Razorpay); // products hosting several adapters
IPaymentGatewayFactory.ResolveDefault() returns the one registered adapter when there is
exactly one. No gateway package nominates a default, because a package that nominated itself
silently changed the default of a product that added it as a second gateway. With several adapters
and no nomination, ResolveDefault() throws rather than guessing: state the default with
UseDefaultPaymentGateway(PaymentGateway.X), or register
new DefaultPaymentGateway(PaymentGateway.X) directly where that helper is not referenced.
IPayoutGatewayFactory.ResolveDefault() follows the same rule, with DefaultPayoutGateway as its
nomination record. Registering only Razorpay payouts needs no nomination, because it is then the
only adapter.
Config section Razorpay: KeyId + KeySecret (payments), WebhookSecret (inbound webhooks),
PayoutAccountNumber (the Razorpay X account number — payout heads only), BaseUrl (validated at
start-up as an absolute https address), HttpTimeoutSeconds (default 30, must be greater than zero).
Typical DI (API head that hosts webhooks + recurring billing):
services.AddRazorpay(configuration);
services.AddRazorpayGateway(); // recurring adapter + webhook verify/map + factory
services.AddRazorpayWebhooks(); // API head only — requires WebhookSecret when keys are set
services.AddRazorpayPaymentCollection(); // if the head creates orders / term payments
// Product AddBillingServices: the orchestrator only: do not re-register Razorpay types
Disputes
A customer dispute (chargeback) arrives as one of six events, each carrying envelope.Dispute
(GatewayDispute: Id, PaymentId, AmountMinor, Currency, ReasonCode,
RespondByDateTimeUtc, Status). ResourceId is the dispute id. Status is the gateway's own
string, passed through unmapped.
| Event | Razorpay | Paddle |
|---|---|---|
DisputeCreated |
payment.dispute.created |
adjustment.created / adjustment.updated with action chargeback or chargeback_warning |
DisputeUnderReview |
payment.dispute.under_review |
none |
DisputeWon |
payment.dispute.won |
action chargeback_reverse |
DisputeLost |
payment.dispute.lost |
none |
DisputeClosed |
payment.dispute.closed |
none |
DisputeActionRequired |
payment.dispute.action_required |
none |
- Razorpay: act before
RespondByDateTimeUtc. The merchant submits evidence in the Razorpay dashboard by that date, so a product should surface it onDisputeCreatedandDisputeActionRequired. The payload also carries the disputed payment, left onenvelope.Payment. - Paddle: informational only. Paddle is the merchant of record and handles the dispute with
the card network itself, so there is no evidence deadline (
RespondByDateTimeUtcis null) and no reason code (ReasonCodecarries Paddle's reason text). The product's job is to record that the payment was disputed and, onDisputeWon, that it was reversed. Paddle sends no under-review, lost, closed or action-required delivery. - Amounts are
longminor units; an unreadable amount is 0, as on refunds.
Breaking changes in 0.10.0
Binary breaks for a consumer compiled against 0.9.x; every consumer recompiles (a re-pin alone
throws MissingMethodException at runtime). All three are in the new Paddle package's own wire
layer, so today no published consumer is affected:
IPaddleClient.CreateCustomerPortalUrlAsync(customerId, subscriptionIds, ct)gained a requiredsubscriptionIdsparameter (passnullfor an unscoped session).PaddleSubscriptionItemandPaddleTransactionLineItemgained a trailingProductIdpositional parameter;PaddleTransactiongained a trailingItemProductIds. Positional deconstruction and Moq setups on the old arity must be updated.PaddleWebhookEventMapperrequiresIOptions<PaddleOptions>; the logger-only constructor is gone so a host that cannot resolve the options fails to construct instead of running unfiltered.
What's new in 0.10.0
Webority.Payments.Paddle— a fourth package: the Paddle Billing adapter described under Consume → Paddle. Recurring surface only for now (no collection / links / payouts seams; Paddle has no payout rail and its one-time checkout goes through the same transaction API).CreateSubscriptionResult.CheckoutReference(Abstractions, additive) — the gateway's checkout handle when the subscription does not exist yet. Null for Razorpay.GatewayEventType.SubscriptionUpdated(Abstractions, additive, appended) — Paddle'ssubscription.updated, carrying the new subscription state (scheduled cancel, item change). Consumers that only track lifecycle ignore it; aswitchwith adefaultarm is unaffected.- Configurable default gateway (Abstractions, additive) —
DefaultPaymentGatewayrecord + a secondPaymentGatewayFactoryconstructor. (Superseded after 0.12.0: several adapters with no nomination now throw instead of falling back to Razorpay.) - Paddle fixtures in
Webority.Payments.Testing—PaddleWebhookFixtures: the eight official example payloads (transaction completed / payment failed, subscription activated / trialing / updated / past due / canceled, adjustment updated).
What's new in 0.8.0
AddRazorpayGateway()— opt-in, additive DI for the recurring-billing surface. RegistersRazorpayPaymentGatewayServiceasIPaymentGatewayService, the Razorpay webhook signature verifier and event mapper, and the defaultPaymentGatewayFactory(viaTryAdd/TryAddEnumerable). Call afterAddRazorpay(). Does not register Stripe/Paddle stubs, does not enforce webhook-secret validation (that stays onAddRazorpayWebhooks()for API heads).Adoption caveat (boot-time failure if ignored): replace the product's existing Razorpay adapter / verifier / mapper / factory lines with this call. Do not stack a plain
AddScoped<IPaymentGatewayService, RazorpayPaymentGatewayService>()afterAddRazorpayGateway().PaymentGatewayFactoryindexes byGatewaywithToDictionary— a second Razorpay registration throws at resolve. CallingAddRazorpayGateway()twice, or calling it when the product already registered the same types first, is safe (TryAddskips).Webority.Payments.Testing— new third package. Ships official Razorpay webhook JSON fixtures (embedded resources) and three framework-agnostic orchestrator scenarios (DuplicateDeliveryIsNoOp,ChargedSynthesisedInvoiceRecordsPaymentOnce,RefundProcessedOnceDoesNotDoubleApply) that take process/probe delegates and throwInvalidOperationExceptionon failure — no base class, no EF, no test-framework dependency. This package's own test suite (Webority.Payments.Tests) is the first consumer: mapper tests load fixtures from Testing, so the embedded path and fixture↔mapper agreement are proven in CI.Full refund —
CreateRefundRequest.AmountMinor = nullnow means a full refund at the gateway (the amount key is omitted on the wire). Previously the Razorpay adapter threwNotSupportedExceptionon null, contradicting the Abstractions contract. Partial refunds (explicit amount) are unchanged. Products that already always pass an explicit amount need no change; products that relied on the throw will now get a real full refund instead.GetMaxBillingCycles(BillingInterval)— new default interface method onIPaymentGatewayService. Default isint.MaxValue(no platform cap). Existing implementors (including product Stripe stubs) need no source change on recompile — the default applies. Razorpay overrides publicly with platform caps: monthly 120, quarterly 40, yearly 100. Products that still hard-code those numbers in the orchestrator can switch toadapter.GetMaxBillingCycles(interval)when they next touch billing; the upgrade alone does not remove product-side duplication.
Breaking changes in 0.9.0
Every date/time member now follows the fleet naming convention (dotnet.md, billing spec
M13): a UTC timestamp ends …DateTimeUtc; the suffixes At, On and a bare
Start/End/Expires are gone. The library previously broke the rule it asks products to
satisfy, and was internally inconsistent — the newest members were already correct while older
ones were not, which reads as if there is no rule at all.
Rename map — mechanical, names only, no behaviour or type changed:
| Old | New |
|---|---|
GatewaySubscription.CurrentPeriodStart |
CurrentPeriodStartDateTimeUtc |
GatewaySubscription.CurrentPeriodEnd |
CurrentPeriodEndDateTimeUtc |
GatewaySubscription.TrialEnd |
TrialEndDateTimeUtc |
CreateSubscriptionRequest.TrialEndsAt |
TrialEndDateTimeUtc |
GatewayPayment.CapturedAt |
CapturedDateTimeUtc |
GatewayRefund.CreatedAt |
CreatedDateTimeUtc |
GatewayPayout.CreatedAt |
CreatedDateTimeUtc |
GatewayInvoice.DueDate |
DueDateTimeUtc (it is a timestamp, so …Date was the wrong kind) |
InvoiceListFilter.CreatedAfter / CreatedBefore |
CreatedAfterDateTimeUtc / CreatedBeforeDateTimeUtc |
PayoutListFilter.From / To |
FromDateTimeUtc / ToDateTimeUtc |
The Razorpay.Http wire DTOs were renamed on the same principle — a provider's naming is a
mapping detail that stays inside this library and must not leak into our surface. Their unix-epoch
fields are now uniformly …UnixSeconds (CreatedAtUnix → CreatedUnixSeconds, StartAtUnix →
StartUnixSeconds, CurrentStartUnix → CurrentPeriodStartUnixSeconds, and so on).
The JSON keys on the wire are untouched — "created_at", "current_start", "trial_end" and
friends are Razorpay's protocol, not a naming choice of ours. Only C# identifiers moved.
Migrating: rename at the call site; the compiler finds every one. There is no behavioural change to verify, so a green build is a complete migration.
Breaking changes in 0.8.0
Webority.Payments.Razorpay.Http.CreateRefundRequest.AmountMinoris nowlong?. Null means omit amount on the wire (full refund). Anyone constructing this wire DTO (not the neutralAbstractions.Models.CreateRefundRequest, which was alreadylong?) or reading the field into a non-nullablelongmust adjust. No product in the fleet constructs the wire type today; the neutral seam is unaffected.
Breaking changes in 0.7.0
Consumers upgrading from 0.6.0 must apply both:
RazorpayOptions,RazorpayIntervalMapandRazorpayNotConfiguredExceptionmoved out ofWebority.Payments.Razorpay.HttptoWebority.Payments.Razorpay.Httpis the wire layer; configuration and domain mapping are not wire concerns. Code that aliased the whole namespace (using Rp = Webority.Payments.Razorpay.Http;thenRp.RazorpayOptions) drops the alias for those three types.IRazorpayClient,RazorpayClient, the wire DTOs andRazorpayApiExceptionstay inHttp.GatewayWebhookEnvelopegained a trailingPaymentLinkparameter. It defaults tonull, so only code constructing the envelope positionally with every argument is affected — consumers that read the envelope need no change.
To issue payment links, add AddRazorpayPaymentLinks() after AddRazorpay() and subscribe the
payment_link.paid / payment_link.partially_paid / payment_link.expired /
payment_link.cancelled webhook events.
Release
Merge development → main publishes packages to nuget.org at the
Directory.Build.props VersionPrefix (the PR gate requires bumping it). Pushes use
--skip-duplicate, so a re-run is a no-op. Prefer additive changes; a breaking change to the seam
is a billing-spec version event, and every consuming product must be updated in the same pass.
Record each one under "Breaking changes" above so consumers know what to apply.
Develop
dotnet build Webority.Payments.slnx
dotnet test Webority.Payments.Tests
To iterate against a product before releasing, switch the product's PackageReference to a
local ProjectReference temporarily — never copy package sources back into a product.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Webority.Payments.Abstractions (>= 0.17.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.