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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Webority.Payments.Testing" Version="0.17.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Webority.Payments.Testing" Version="0.17.0" />
                    
Directory.Packages.props
<PackageReference Include="Webority.Payments.Testing" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Webority.Payments.Testing --version 0.17.0
                    
#r "nuget: Webority.Payments.Testing, 0.17.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Webority.Payments.Testing@0.17.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Webority.Payments.Testing&version=0.17.0
                    
Install as a Cake Addin
#tool nuget:?package=Webority.Payments.Testing&version=0.17.0
                    
Install as a Cake Tool

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. CreateSubscriptionAsync now throws NotSupportedByGatewayException when the request carries a TrialEndDateTimeUtc, where 0.10.0 logged a warning and went on to charge the card. Move the trial onto the price with CreatePlanRequest.TrialDays. Check Capabilities.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.TaxMinor and CreatePlanRequest.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 signs payment_link_id|reference_id|status|payment_id. Each seam exposes its own verify method — verifying a link redirect with VerifyCheckoutSignature rejects 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. CreateSubscriptionAsync creates a transaction and returns its id as CreateSubscriptionResult.CheckoutReference (plus RedirectUrl when 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 in transaction.completed / subscription.created. The Metadata passed to create travels as custom_data and comes back on envelope.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 to SubscriptionCharged when it names a subscription. Payment.Id is 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.Number is Paddle's own invoice number, the tax document the customer received.
  • Refunds are adjustments that await Paddle's approval on a live account, so ImmediateRefund is not advertised and CreateRefundAsync returns Pending; the outcome arrives as adjustment.updated (RefundProcessed / RefundFailed).
  • Trials are configured on the price, not per checkout. Pass CreatePlanRequest.TrialDays and Paddle attaches a trial_period to the price, so the subscription created from it starts trialing. Paddle therefore does not advertise TrialOnSubscriptionCreate, and a TrialEndDateTimeUtc on CreateSubscriptionAsync throws NotSupportedByGatewayException: 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's start_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 on DisputeCreated and DisputeActionRequired. The payload also carries the disputed payment, left on envelope.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 (RespondByDateTimeUtc is null) and no reason code (ReasonCode carries Paddle's reason text). The product's job is to record that the payment was disputed and, on DisputeWon, that it was reversed. Paddle sends no under-review, lost, closed or action-required delivery.
  • Amounts are long minor 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:

  1. IPaddleClient.CreateCustomerPortalUrlAsync(customerId, subscriptionIds, ct) gained a required subscriptionIds parameter (pass null for an unscoped session).
  2. PaddleSubscriptionItem and PaddleTransactionLineItem gained a trailing ProductId positional parameter; PaddleTransaction gained a trailing ItemProductIds. Positional deconstruction and Moq setups on the old arity must be updated.
  3. PaddleWebhookEventMapper requires IOptions<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

  1. 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).
  2. CreateSubscriptionResult.CheckoutReference (Abstractions, additive) — the gateway's checkout handle when the subscription does not exist yet. Null for Razorpay.
  3. GatewayEventType.SubscriptionUpdated (Abstractions, additive, appended) — Paddle's subscription.updated, carrying the new subscription state (scheduled cancel, item change). Consumers that only track lifecycle ignore it; a switch with a default arm is unaffected.
  4. Configurable default gateway (Abstractions, additive) — DefaultPaymentGateway record + a second PaymentGatewayFactory constructor. (Superseded after 0.12.0: several adapters with no nomination now throw instead of falling back to Razorpay.)
  5. 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

  1. AddRazorpayGateway() — opt-in, additive DI for the recurring-billing surface. Registers RazorpayPaymentGatewayService as IPaymentGatewayService, the Razorpay webhook signature verifier and event mapper, and the default PaymentGatewayFactory (via TryAdd / TryAddEnumerable). Call after AddRazorpay(). Does not register Stripe/Paddle stubs, does not enforce webhook-secret validation (that stays on AddRazorpayWebhooks() 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>() after AddRazorpayGateway(). PaymentGatewayFactory indexes by Gateway with ToDictionary — a second Razorpay registration throws at resolve. Calling AddRazorpayGateway() twice, or calling it when the product already registered the same types first, is safe (TryAdd skips).

  2. 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 throw InvalidOperationException on 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.

  3. Full refund — CreateRefundRequest.AmountMinor = null now means a full refund at the gateway (the amount key is omitted on the wire). Previously the Razorpay adapter threw NotSupportedException on 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.

  4. GetMaxBillingCycles(BillingInterval) — new default interface method on IPaymentGatewayService. Default is int.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 to adapter.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

  1. Webority.Payments.Razorpay.Http.CreateRefundRequest.AmountMinor is now long?. Null means omit amount on the wire (full refund). Anyone constructing this wire DTO (not the neutral Abstractions.Models.CreateRefundRequest, which was already long?) or reading the field into a non-nullable long must 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:

  1. RazorpayOptions, RazorpayIntervalMap and RazorpayNotConfiguredException moved out of Webority.Payments.Razorpay.Http to Webority.Payments.Razorpay. Http is the wire layer; configuration and domain mapping are not wire concerns. Code that aliased the whole namespace (using Rp = Webority.Payments.Razorpay.Http; then Rp.RazorpayOptions) drops the alias for those three types. IRazorpayClient, RazorpayClient, the wire DTOs and RazorpayApiException stay in Http.
  2. GatewayWebhookEnvelope gained a trailing PaymentLink parameter. It defaults to null, 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.17.0 0 10/3/2026
0.16.0 33 10/2/2026
0.15.1 34 10/2/2026
0.15.0 37 10/1/2026
0.14.0 91 9/22/2026
0.13.0 81 9/22/2026
0.12.0 90 9/22/2026
0.11.0 106 9/10/2026
0.10.0 103 8/28/2026
0.9.1 111 8/18/2026
0.9.0 108 8/9/2026
0.8.0 108 8/9/2026