CashierMollie 0.4.0
dotnet add package CashierMollie --version 0.4.0
NuGet\Install-Package CashierMollie -Version 0.4.0
<PackageReference Include="CashierMollie" Version="0.4.0" />
<PackageVersion Include="CashierMollie" Version="0.4.0" />
<PackageReference Include="CashierMollie" />
paket add CashierMollie --version 0.4.0
#r "nuget: CashierMollie, 0.4.0"
#:package CashierMollie@0.4.0
#addin nuget:?package=CashierMollie&version=0.4.0
#tool nuget:?package=CashierMollie&version=0.4.0
<p align="center"> <img src="https://www.mollie.com/assets/images/branding/mollie-logo.svg" alt="Mollie" width="200"> </p>
<h1 align="center">CashierMollie for .NET</h1>
<p align="center"> <strong>Mollie subscription management for ASP.NET Core</strong><br> A .NET port of the excellent <a href="https://github.com/mollie/laravel-cashier-mollie">mollie/laravel-cashier-mollie</a> package. </p>
<p align="center"> <a href="https://www.nuget.org/packages/CashierMollie"><img src="https://img.shields.io/nuget/v/CashierMollie.svg" alt="NuGet"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a> <img src="https://img.shields.io/badge/.NET-10.0-purple.svg" alt=".NET 10"> </p>
CashierMollie provides a fluent, expressive API for managing Mollie recurring payments and subscriptions in ASP.NET Core applications. It handles the complexities of subscription billing so you can focus on your product.
Acknowledgement: This project is heavily inspired by and aims for feature parity with the outstanding laravel-cashier-mollie package, originally created by Sander van Hooft and maintained by the Laravel/Mollie community. Their thoughtful API design and thorough approach to subscription billing formed the blueprint for this .NET implementation. Thank you for paving the way!
Table of Contents
- Features
- Requirements
- Installation
- Configuration
- Getting Started
- Subscriptions
- Billing Engines
- Coupons
- Credits
- One-off Charges
- Refunds
- Payment Method Update
- First Payment & Mandates
- Security -- webhook trust invariant, read before writing a custom handler
- Webhooks
- Events
- Database Schema
- API Reference
- Configuration Reference
- Roadmap
- Contributing
- Credits
- License
Features
- Subscription lifecycle -- create, cancel, resume, and swap subscriptions with a fluent API
- Dual billing engine -- choose between Mollie's native Subscription API (
MollieNative) or a self-managed billing cycle (Managed) with background processing - First payment flow -- automatic mandate creation via Mollie checkout or direct mandate-based activation
- Trial periods -- built-in support for trial days
- Grace periods -- cancelled subscriptions remain active until the billing period ends
- Quantity management -- update, increment, and decrement subscription quantities (seat-based billing)
- Coupon system -- fixed and percentage-based discounts with pluggable
ICouponRepositoryandICouponHandler - Credit / balance system -- add, apply, and check account credit balances per currency
- One-off charges -- fluent
IChargeBuilderfor single payments with or without mandate - Refund system -- partial and complete refunds via Mollie with local tracking
- Payment method update -- initiate payment method changes via first payment flow
- Mandate management -- check mandate validity and revoke mandates
- Chargeback detection -- automatic tracking and events for chargebacks
- Invoice interface -- pluggable
IInvoiceGenerator<TKey>for custom invoice generation (sevdesk, PDF, etc.) - Webhook handling -- opt-in middleware for automatic processing of Mollie payment notifications
- Event system -- 23 domain events via pluggable
ICashierEventDispatcher - EF Core integration -- 7 entities across 7 tables with a single
ModelBuilderextension - Generic owner key -- supports
string,int,Guid, or anyIEquatable<TKey>type for your user model - Dependency injection -- first-class ASP.NET Core DI support with a single
AddCashierMollie<TKey>()call
Requirements
- .NET 10 or later
- Entity Framework Core 10
- Mollie.Api 4.18+
- A Mollie account with recurring payments enabled
Installation
dotnet add package CashierMollie
dotnet add package Mollie.Api
CashierMollie wraps Mollie.Api but does not bundle it -- you register the Mollie API clients yourself, giving you full control over Mollie configuration.
Configuration
Add a CashierMollie section to your appsettings.json:
{
"CashierMollie": {
"ApiKey": "test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"Currency": "EUR",
"Locale": "de_DE",
"WebhookPath": "/cashier/webhook",
"WebhookUrl": "https://yourdomain.com/cashier/webhook",
"FirstPaymentRedirectUrl": "https://yourdomain.com/billing/return"
}
}
⚠️
WebhookPathandWebhookUrlare two different settings and you need both.WebhookPathis the local path the middleware listens on.WebhookUrlis the absolute URL handed to Mollie so it knows where to call you. Their path components must match, or Mollie will call a path your application does not serve.If you leave
WebhookUrlempty it falls back toWebhookPath-- which is a relative path, and Mollie requires an absolute HTTPS URL. That combination fails only once real payments start, so setWebhookUrlexplicitly. The library logs a warning at startup when the effective URL is not absolute (since 0.4.0).
Important: For production, use
live_xxxAPI keys. TheWebhookUrlmust be a publicly accessible HTTPS URL. For local development, use a tunnel service like ngrok or Cloudflare Tunnel.
Getting Started
1. Implement IBillable on your user model
using CashierMollie.Interfaces;
using Microsoft.AspNetCore.Identity;
public class AppUser : IdentityUser, IBillable<string>
{
// These are required by IBillable<TKey>:
// string Id -- inherited from IdentityUser
// string? Email -- inherited from IdentityUser
// string? Name -- you may need to add this
public string? MollieCustomerId { get; set; }
public string? MollieMandateId { get; set; }
public string? Name { get; set; }
}
IBillable<TKey> works with any key type. For example, with int keys:
public class AppUser : IBillable<int>
{
public int Id { get; set; }
public string? MollieCustomerId { get; set; }
public string? MollieMandateId { get; set; }
public string? Email { get; set; }
public string? Name { get; set; }
}
2. Register services
In your Program.cs:
using CashierMollie.Extensions; // AddCashierMollie, UseCashierWebhook
using Mollie.Api; // AddMollieApi <-- NOT Mollie.Api.Extensions
var builder = WebApplication.CreateBuilder(args);
// Register Mollie API clients (from the Mollie.Api package)
builder.Services.AddMollieApi(options =>
{
options.ApiKey = builder.Configuration["CashierMollie:ApiKey"];
});
// Register CashierMollie services
builder.Services.AddCashierMollie<string>(builder.Configuration);
var app = builder.Build();
// Enable automatic webhook handling (optional -- see Webhooks section)
app.UseCashierWebhook();
app.Run();
Watch the namespaces -- they differ, and the wrong guess fails to compile with no hint where to look.
AddCashierMollieandUseCashierWebhooklive inCashierMollie.Extensions, butAddMollieApilives inMollie.Api-- not inMollie.Api.Extensions, which exists but holds only internal helpers. You write these two registration calls in one breath, so it is natural to assume oneusingcovers both. Our first production consumer had to decompile the package to find this.
3. Database setup
CashierMollie uses Entity Framework Core for data persistence. Apply the schema to your existing DbContext:
using CashierMollie.Data;
public class AppDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Adds all 7 CashierMollie tables: cashier_subscriptions, cashier_order_items,
// cashier_payments, cashier_orders, cashier_credits, cashier_refunds,
// cashier_redeemed_coupons
modelBuilder.ApplyCashierMollie<string>();
}
}
Then generate and apply migrations:
dotnet ef migrations add AddCashierMollieTables
dotnet ef database update
Note: You can also provide a separate
DbContextfor CashierMollie via thedbContextOptionsparameter inAddCashierMollie<TKey>()if you prefer to keep billing tables in a separate database.
Subscriptions
Creating subscriptions
Inject ICashierService<TKey> and use the fluent subscription builder:
public class BillingController : Controller
{
private readonly ICashierService<string> _cashier;
public BillingController(ICashierService<string> cashier)
{
_cashier = cashier;
}
public async Task<IActionResult> Subscribe(AppUser user)
{
var result = await _cashier.NewSubscription(user, "default", "pro-monthly")
.CreateAsync();
if (result.RequiresAction)
{
// User needs to complete the first payment via Mollie checkout
return Redirect(result.CheckoutUrl!);
}
// Subscription is active immediately (user already has a mandate)
return RedirectToAction("Dashboard");
}
}
The three arguments to NewSubscription are:
| Parameter | Description |
|---|---|
owner |
Your user model implementing IBillable<TKey> |
name |
A label for this subscription (e.g. "default", "extra") -- used to distinguish multiple subscriptions per user |
plan |
The plan identifier matching your Mollie configuration |
Checking subscription status
// Is the user subscribed to the "default" subscription?
bool isSubscribed = await _cashier.IsSubscribedAsync(user, "default");
// Is the subscription on a trial period?
bool onTrial = await _cashier.OnTrialAsync(user, "default");
// Is the subscription cancelled but still within the grace period?
bool onGracePeriod = await _cashier.OnGracePeriodAsync(user, "default");
bool isCancelled = await _cashier.IsCancelledAsync(user, "default");
// Get the full subscription object for detailed inspection
var subscription = await _cashier.GetSubscriptionAsync(user, "default");
if (subscription != null)
{
bool isActive = subscription.IsActive();
bool isCancelled = subscription.IsCancelled();
bool isEnded = subscription.IsEnded();
bool onTrial = subscription.OnTrial();
}
// Get all subscriptions for a user
var allSubs = await _cashier.GetSubscriptionsAsync(user);
Cancelling subscriptions
// Cancel at end of billing period (grace period)
await _cashier.CancelAsync(user, "default");
When cancelled, the subscription enters a grace period. The user retains access until the current billing period ends. During this time:
IsSubscribedAsync()returnstrueOnGracePeriodAsync()returnstruesubscription.IsCancelled()returnstrue
To cancel immediately without a grace period:
await _cashier.CancelImmediatelyAsync(user, "default");
Resuming subscriptions
A subscription can be resumed during its grace period:
await _cashier.ResumeAsync(user, "default");
After resuming, the subscription is active again and OnGracePeriodAsync() returns false. Once the grace period has expired (IsEnded() returns true), a new subscription must be created.
Swapping plans
// Swap to a different plan
var updated = await _cashier.SwapAsync(user, "default", "team-monthly");
Trial periods
var result = await _cashier.NewSubscription(user, "default", "pro-monthly")
.TrialDays(14)
.CreateAsync();
// Check trial status
bool onTrial = await _cashier.OnTrialAsync(user, "default");
During the trial period, the user is considered subscribed (IsSubscribedAsync() returns true).
Grace periods
When a subscription is cancelled, it enters a grace period until the end of the current billing cycle. During this time the user still has access to the subscribed features.
await _cashier.CancelAsync(user, "default");
// Still has access
bool subscribed = await _cashier.IsSubscribedAsync(user, "default"); // true
bool onGrace = await _cashier.OnGracePeriodAsync(user, "default"); // true
// Can resume during grace period
await _cashier.ResumeAsync(user, "default");
Multiple subscriptions
A single user can hold multiple subscriptions, each identified by a unique name:
// Primary subscription
await _cashier.NewSubscription(user, "default", "pro-monthly").CreateAsync();
// Add-on subscription
await _cashier.NewSubscription(user, "addons", "extra-storage").CreateAsync();
// Check independently
bool hasPro = await _cashier.IsSubscribedAsync(user, "default");
bool hasAddons = await _cashier.IsSubscribedAsync(user, "addons");
// Cancel only the add-on
await _cashier.CancelAsync(user, "addons");
Quantity management
Update subscription quantities for seat-based billing:
// Set quantity to 10
await _cashier.UpdateQuantityAsync(user, "default", 10);
// Add 3 seats
await _cashier.IncrementQuantityAsync(user, "default", 3);
// Remove 2 seats (minimum 1)
await _cashier.DecrementQuantityAsync(user, "default", 2);
Billing Engines
CashierMollie supports two billing strategies via the IBillingEngine<TKey> strategy pattern:
MollieNative (default) -- delegates billing to Mollie's Subscription API. Mollie manages the billing cycle and sends webhooks for each payment.
Managed -- runs its own billing cycle locally. Schedules OrderItem records with ProcessAt dates and creates on-demand recurring payments via Mollie mandates. Requires the CashierBackgroundService (registered automatically).
{
"CashierMollie": {
"BillingEngine": "Managed",
"ProcessingInterval": "01:00:00"
}
}
Set
BillingEngineexplicitly, even when you want the default. The two engines have different failure semantics -- see who retries a failed payment. An application that never sets the key stands on the default implicitly, so a change to the default would not be a configuration change for you -- it would be a silent behaviour change in your failure path. The default is therefore part of this library's public contract: changing it is a breaking change and would only ship in a major version.The engine actually in effect is written to the log at startup, so you can confirm which semantics you are running rather than infer them.
Coupons
CashierMollie includes a coupon system with fixed and percentage-based discounts.
// Validate and redeem a coupon
var couponService = serviceProvider.GetRequiredService<ICouponService<string>>();
await couponService.ValidateAsync("LAUNCH10");
await couponService.RedeemAsync("LAUNCH10", subscription, user.Id);
Configure coupons in appsettings.json (default ConfigCouponRepository) or register your own ICouponRepository:
{
"CashierMollie": {
"Coupons": [
{ "Code": "LAUNCH10", "Type": "percentage", "Value": 10, "MaxRedemptions": 100 },
{ "Code": "5OFF", "Type": "fixed", "Value": 5.00 }
]
}
}
Credits
Manage per-user account credit balances:
var creditService = serviceProvider.GetRequiredService<ICreditService<string>>();
// Add credit
await creditService.AddCreditAsync(user, 25.00m, "EUR", "Referral bonus");
// Check balance
decimal balance = await creditService.GetBalanceAsync(user, "EUR");
// Apply credit (returns amount actually applied, capped at balance)
decimal applied = await creditService.ApplyCreditAsync(user, 10.00m, "EUR");
One-off Charges
Create single payments using the fluent IChargeBuilder:
var result = await _cashier.NewCharge(user, 49.99m)
.WithDescription("Premium add-on")
.CreateAsync();
if (result.RequiresAction)
return Redirect(result.CheckoutUrl!);
Refunds
Issue partial or complete refunds for payments:
var refundService = serviceProvider.GetRequiredService<IRefundService<string>>();
// Partial refund (pass the Payment<TKey> entity)
var refund = await refundService.RefundAsync(payment, 10.00m, "Partial refund");
// Full refund
var fullRefund = await refundService.RefundCompletelyAsync(payment);
Payment Method Update
Allow users to update their payment method:
var result = await _cashier.UpdatePaymentMethodAsync(user);
// Redirect user to result.CheckoutUrl to complete the update
First Payment & Mandates
Mollie uses mandates for recurring payments. A mandate authorizes you to charge the customer's payment method on a recurring basis.
CashierMollie handles this automatically:
If the user already has a mandate (MollieMandateId is set on the IBillable):
- The subscription is activated immediately
- No checkout redirect needed
If the user has no mandate:
- A "first payment" is created via the Mollie API
- The user is redirected to Mollie's checkout to authorize recurring payments
- After successful payment, Mollie sends a webhook to activate the subscription
var result = await _cashier.NewSubscription(user, "default", "pro")
.CreateAsync();
if (result.RequiresAction)
{
// Redirect to Mollie checkout for mandate creation
return Redirect(result.CheckoutUrl!);
}
// Already has mandate -- subscription is active
You can force mandate-only mode (authorization without charging):
var result = await _cashier.NewSubscription(user, "default", "pro")
.WithMandateOnly()
.CreateAsync();
Security
The webhook trust invariant -- read this before writing your own handler
Mollie webhooks are not signed. There is no HMAC, no shared secret, and no way to prove a request came from Mollie. Your webhook endpoint must be publicly reachable, so anyone on the internet can POST anything to it.
CashierMollie is safe under that assumption because of one deliberate rule:
The webhook body is untrusted input. The only thing ever read from it is the
idfield, and that is used solely as a lookup key. Every value that changes state -- status, amount, refunds, chargebacks -- is fetched from Mollie over an authenticated API call.
That is an invariant, not an implementation detail, and it can be broken from the outside:
// DANGEROUS -- do not do this in a custom handler.
// Saves one API call and turns a public, unauthenticated endpoint into a
// "mark any payment as paid" primitive for anyone who can guess a payment ID.
[HttpPost("mollie-webhook")]
public async Task<IActionResult> Handle([FromForm] string id, [FromForm] string status)
{
if (status == "paid") await _orders.MarkPaidAsync(id); // trusts the body
return Ok();
}
The safe version never believes the payload:
[HttpPost("mollie-webhook")]
public async Task<IActionResult> Handle([FromForm] string id)
{
await _webhook.HandlePaymentAsync(id); // re-fetches from Mollie internally
return Ok();
}
If you use UseCashierWebhook(), this is handled for you. If you implement your own endpoint
around IWebhookService, you take ownership of the invariant. Treat the payload as nothing more
than a hint that something about payment id may have changed.
Additional hardening worth applying: rate-limit the endpoint (it is public and unauthenticated by nature), and do not log the raw body at info level -- it is attacker-controlled.
Webhooks
Before customising anything here, read The webhook trust invariant. Mollie does not sign webhooks; safety depends on never trusting the request body.
Mollie uses webhooks to notify your application about payment status changes. CashierMollie provides two approaches.
Webhook middleware (recommended)
The simplest approach -- register the middleware and CashierMollie handles everything:
app.UseCashierWebhook(); // Handles POST requests to /cashier/webhook
The middleware:
- Intercepts POST requests to the configured
WebhookPath(notWebhookUrl-- see Configuration) - Reads the
idform field (Mollie payment ID) - Fetches the payment status from Mollie's API
- Updates the local payment record
- Dispatches events
- Returns HTTP 200 on success or business logic errors; HTTP 500 on infrastructure errors (so Mollie retries)
Manual webhook handling
If you need more control, inject IWebhookService directly:
[ApiController]
[Route("billing")]
public class BillingWebhookController : ControllerBase
{
private readonly IWebhookService _webhook;
public BillingWebhookController(IWebhookService webhook)
{
_webhook = webhook;
}
[HttpPost("mollie-webhook")]
public async Task<IActionResult> HandleMollieWebhook([FromForm] string id)
{
await _webhook.HandlePaymentAsync(id);
return Ok();
}
}
⚠️ You have just taken ownership of the webhook trust invariant. The handler above is safe only because it passes
idand nothing else --HandlePaymentAsyncre-fetches the payment from Mollie. Mollie does not sign webhooks, so any additional form field you bind here is attacker-controlled. Do not use the payload to decide payment state, however tempting it is to skip the extra API call. See Security for the failure mode in full, and rate-limit this endpoint in production.
Events
CashierMollie dispatches domain events throughout the subscription and payment lifecycle via ICashierEventDispatcher.
Who retries a failed payment? (read before handling OrderPaymentFailed)
Events are notifications, not instructions -- but do not read that as "you can ignore them". There is no dunning logic in this library: no retry cap, no backoff schedule, no automatic subscription suspension, no customer notification. What happens after a failure depends entirely on the billing engine:
| Engine | What retries | What you still own |
|---|---|---|
MollieNative (default) |
Mollie, on its own subscription retry schedule. Not this library. | Deciding what happens when Mollie stops retrying -- suspend access, notify the customer, escalate. |
Managed |
Nothing explicitly. The failed OrderItem is simply left unconsumed, so the next billing cycle picks it up again -- retry by omission, with no attempt limit and no backoff. |
Capping attempts and giving up. Without that, a permanently failing item is retried forever. |
Do not assume the library will eventually give up and suspend the subscription -- it will not.
Dunning/retry is a documented gap on the Roadmap. If revenue depends on it, implement
it in your ICashierEventDispatcher.
Known API wart:
OrderPaymentFailedDueToInvalidMandate<TKey>is declared and unit-tested but is never dispatched by any code path in this version. Do not build on it.OrderPaymentFailedis the event you will actually receive.
Available events
| Event | Description |
|---|---|
SubscriptionCreated<TKey> |
A new subscription was activated |
SubscriptionCancelled<TKey> |
A subscription was cancelled |
SubscriptionResumed<TKey> |
A cancelled subscription was resumed |
SubscriptionPlanSwapped<TKey> |
A subscription plan was changed |
SubscriptionQuantityUpdated<TKey> |
Subscription quantity was updated |
OrderPaymentPaid<TKey> |
A payment was successfully processed |
OrderPaymentFailed<TKey> |
A payment attempt failed -- see who retries |
OrderPaymentFailedDueToInvalidMandate<TKey> |
⚠️ Never dispatched in this version. Declared and unit-tested, but no code path raises it. Do not build on it. |
FirstPaymentPaid<TKey> |
First payment (mandate creation) succeeded |
FirstPaymentFailed<TKey> |
First payment (mandate creation) failed |
MandateUpdated<TKey> |
Owner's mandate was updated |
MandateCleared<TKey> |
Owner's mandate was removed |
CouponApplied<TKey> |
A coupon was redeemed on a subscription |
CreditAdded<TKey> |
Credit was added to owner's balance |
CreditApplied<TKey> |
Credit was consumed from owner's balance |
RefundInitiated<TKey> |
A refund was initiated |
RefundProcessed<TKey> |
A refund was processed successfully |
RefundFailed<TKey> |
A refund attempt failed |
ChargebackReceived<TKey> |
A chargeback was received |
OrderCreated<TKey> |
A new order was created |
OrderProcessed<TKey> |
An order was fully processed |
OrderInvoiceAvailable<TKey> |
An invoice is available for an order |
BalanceTurnedStale<TKey> |
Owner's credit balance became stale |
Handling events
Implement ICashierEventDispatcher and register it in DI:
using CashierMollie.Events;
using CashierMollie.Interfaces;
public class CashierEventHandler : ICashierEventDispatcher
{
private readonly ILogger<CashierEventHandler> _logger;
public CashierEventHandler(ILogger<CashierEventHandler> logger)
{
_logger = logger;
}
public Task DispatchAsync<T>(T @event, CancellationToken ct = default) where T : notnull
{
return @event switch
{
SubscriptionCreated<string> e =>
OnSubscriptionCreated(e, ct),
SubscriptionCancelled<string> e =>
OnSubscriptionCancelled(e, ct),
OrderPaymentPaid<string> e =>
OnPaymentReceived(e, ct),
OrderPaymentFailed<string> e =>
OnPaymentFailed(e, ct),
_ => Task.CompletedTask
};
}
private async Task OnSubscriptionCreated(SubscriptionCreated<string> e, CancellationToken ct)
{
_logger.LogInformation("Subscription '{Name}' created for user {UserId}",
e.Subscription.Name, e.OwnerId);
// Send welcome email, provision resources, etc.
}
private async Task OnSubscriptionCancelled(SubscriptionCancelled<string> e, CancellationToken ct)
{
_logger.LogInformation("Subscription '{Name}' cancelled for user {UserId}",
e.Subscription.Name, e.OwnerId);
}
private async Task OnPaymentReceived(OrderPaymentPaid<string> e, CancellationToken ct)
{
_logger.LogInformation("Payment {PaymentId} received: {Amount} {Currency}",
e.Payment.MolliePaymentId, e.Payment.Amount, e.Payment.Currency);
// Create invoice, send receipt, etc.
}
private async Task OnPaymentFailed(OrderPaymentFailed<string> e, CancellationToken ct)
{
_logger.LogWarning("Payment {PaymentId} failed for user {UserId}",
e.Payment.MolliePaymentId, e.OwnerId);
// Notify user, retry logic, etc.
}
}
Register your handler in DI (before AddCashierMollie or using Replace):
// Option A: Register before AddCashierMollie
builder.Services.AddScoped<ICashierEventDispatcher, CashierEventHandler>();
builder.Services.AddCashierMollie<string>(builder.Configuration);
// Option B: Or after -- TryAddSingleton won't overwrite your registration
builder.Services.AddCashierMollie<string>(builder.Configuration);
builder.Services.AddScoped<ICashierEventDispatcher, CashierEventHandler>();
If you don't register a handler, CashierMollie uses a built-in NullCashierEventDispatcher that silently discards all events.
Database Schema
CashierMollie creates seven tables via the ApplyCashierMollie<TKey>() model builder extension:
cashier_subscriptions
| Column | Type | Description |
|---|---|---|
Id |
bigint PK |
Auto-increment ID |
OwnerId |
TKey |
Foreign key to your user model |
Name |
varchar(255) |
The subscription slot for this owner, not a display name -- lets one owner hold several subscriptions at once, and is what Subscribed(name) matches. Convention: your product (e.g. "acme-app"). Defaults to "default". Matched literally, so changing it orphans the subscription. |
Plan |
varchar(255) |
The priced plan being charged (e.g. "pro-monthly") -- what Swap() changes. Convention: your tier. |
MollieSubscriptionId |
varchar(255)? |
Mollie subscription ID |
MollieCustomerId |
varchar(255)? |
Mollie customer ID |
Status |
varchar(50) |
active, cancelled, past_due, pending, paused |
Quantity |
int? |
Subscription quantity |
TrialEndsAt |
datetimeoffset? |
End of trial period |
EndsAt |
datetimeoffset? |
End of subscription / grace period |
CycleStartedAt |
datetimeoffset? |
Start of current billing cycle |
CreatedAt |
datetimeoffset |
Record creation timestamp |
UpdatedAt |
datetimeoffset |
Last update timestamp |
cashier_payments
| Column | Type | Description |
|---|---|---|
Id |
bigint PK |
Auto-increment ID |
OwnerId |
TKey |
Foreign key to your user model |
SubscriptionId |
bigint? |
Optional FK to subscription |
MolliePaymentId |
varchar(255) UNIQUE |
Mollie payment ID |
Status |
varchar(50) |
Mollie payment status |
Currency |
varchar(3) |
ISO 4217 currency code |
Amount |
decimal |
Payment amount |
MollieMandateId |
varchar(255)? |
Associated mandate ID |
Method |
varchar(50)? |
Payment method used |
PaidAt |
datetimeoffset? |
When payment was confirmed |
FailedAt |
datetimeoffset? |
When payment failed |
CreatedAt |
datetimeoffset |
Record creation timestamp |
UpdatedAt |
datetimeoffset |
Last update timestamp |
cashier_order_items
| Column | Type | Description |
|---|---|---|
Id |
bigint PK |
Auto-increment ID |
SubscriptionId |
bigint |
FK to subscription |
OwnerId |
TKey |
Foreign key to your user model |
Description |
varchar(255) |
Line item description |
Currency |
varchar(3) |
ISO 4217 currency code |
UnitPrice |
decimal |
Price per unit |
Quantity |
int |
Number of units |
TaxPercentage |
decimal |
Tax rate (e.g. 19.0) |
MolliePaymentId |
varchar(255)? |
Associated Mollie payment |
MolliePaymentStatus |
varchar(50)? |
Payment status |
ProcessedAt |
datetimeoffset? |
When item was processed |
CreatedAt |
datetimeoffset |
Record creation timestamp |
UpdatedAt |
datetimeoffset |
Last update timestamp |
cashier_orders
| Column | Type | Description |
|---|---|---|
Id |
bigint PK |
Auto-increment ID |
OwnerId |
TKey |
Foreign key to your user model |
Number |
varchar(255) |
Order number |
MolliePaymentId |
varchar(255)? |
Mollie payment ID |
Status |
varchar(50) |
Order status |
Currency |
varchar(3) |
ISO 4217 currency code |
Total |
decimal |
Order total |
cashier_credits
| Column | Type | Description |
|---|---|---|
Id |
bigint PK |
Auto-increment ID |
OwnerId |
TKey |
Foreign key to your user model |
Currency |
varchar(3) |
ISO 4217 currency code |
Balance |
decimal |
Current credit balance |
cashier_refunds
| Column | Type | Description |
|---|---|---|
Id |
bigint PK |
Auto-increment ID |
PaymentId |
bigint |
FK to payment |
OwnerId |
TKey |
Foreign key to your user model |
MollieRefundId |
varchar(255) UNIQUE |
Mollie refund ID |
Amount |
decimal |
Refund amount |
Currency |
varchar(3) |
ISO 4217 currency code |
Status |
varchar(50) |
Refund status |
cashier_redeemed_coupons
| Column | Type | Description |
|---|---|---|
Id |
bigint PK |
Auto-increment ID |
OwnerId |
TKey |
Foreign key to your user model |
Code |
varchar(255) |
Coupon code |
SubscriptionName |
varchar(255) |
Associated subscription |
TimesLeft |
int? |
Remaining redemptions |
API Reference
ICashierService<TKey>
The main entry point for all subscription operations.
public interface ICashierService<TKey> where TKey : IEquatable<TKey>
{
// Subscription lifecycle
ISubscriptionBuilder<TKey> NewSubscription(IBillable<TKey> owner, string name, string plan);
Task CancelAsync(IBillable<TKey> owner, string name, CancellationToken ct = default);
Task CancelImmediatelyAsync(IBillable<TKey> owner, string name, CancellationToken ct = default);
Task ResumeAsync(IBillable<TKey> owner, string name, CancellationToken ct = default);
Task<Subscription<TKey>> SwapAsync(IBillable<TKey> owner, string name, string newPlan, ...);
// Status checks
Task<bool> IsSubscribedAsync(IBillable<TKey> owner, string name, ...);
Task<bool> OnGracePeriodAsync(IBillable<TKey> owner, string name, ...);
Task<bool> OnTrialAsync(IBillable<TKey> owner, string name, ...);
Task<bool> IsCancelledAsync(IBillable<TKey> owner, string name, ...);
// Retrieve subscriptions
Task<Subscription<TKey>?> GetSubscriptionAsync(IBillable<TKey> owner, string name, ...);
Task<List<Subscription<TKey>>> GetSubscriptionsAsync(IBillable<TKey> owner, ...);
// Quantity management
Task<Subscription<TKey>> UpdateQuantityAsync(IBillable<TKey> owner, string name, int quantity, ...);
Task<Subscription<TKey>> IncrementQuantityAsync(IBillable<TKey> owner, string name, int count = 1, ...);
Task<Subscription<TKey>> DecrementQuantityAsync(IBillable<TKey> owner, string name, int count = 1, ...);
// Customer management
Task<string> GetOrCreateMollieCustomerAsync(IBillable<TKey> owner, ...);
Task UpdateMollieCustomerAsync(IBillable<TKey> owner, ...);
// One-off charges
IChargeBuilder<TKey> NewCharge(IBillable<TKey> owner, decimal amount);
// Payment method & mandate
Task<PaymentMethodUpdateResult> UpdatePaymentMethodAsync(IBillable<TKey> owner, ...);
Task<bool> HasValidMandateAsync(IBillable<TKey> owner, ...);
Task RevokeMandateAsync(IBillable<TKey> owner, ...);
}
ISubscriptionBuilder<TKey>
Fluent builder for creating subscriptions with optional configuration.
public interface ISubscriptionBuilder<TKey> where TKey : IEquatable<TKey>
{
ISubscriptionBuilder<TKey> WithCoupon(string coupon);
ISubscriptionBuilder<TKey> TrialDays(int days);
ISubscriptionBuilder<TKey> WithProration();
ISubscriptionBuilder<TKey> WithMandateOnly();
ISubscriptionBuilder<TKey> WithMetadata(Dictionary<string, string> metadata);
Task<SubscriptionResult<TKey>> CreateAsync(CancellationToken ct = default);
}
Subscription<TKey> model
The subscription entity with computed status methods:
subscription.IsActive(); // Active and not ended
subscription.IsCancelled(); // Has an end date set
subscription.OnGracePeriod(); // Cancelled but end date is in the future
subscription.OnTrial(); // Trial period is active
subscription.IsEnded(); // End date has passed
Configuration Reference
All settings are bound from the CashierMollie section in your configuration:
| Key | Type | Default | Description |
|---|---|---|---|
ApiKey |
string |
"" |
Your Mollie API key (test_xxx for testing, live_xxx for production) |
Currency |
string |
"EUR" |
Default currency for payments (ISO 4217) |
Locale |
string |
"de_DE" |
Locale for Mollie checkout pages (e.g. "en_US", "nl_NL", "de_DE") |
WebhookPath |
string |
"/cashier/webhook" |
Local path the middleware matches. Must correspond to the path component of WebhookUrl. |
WebhookUrl |
string |
"" (falls back to WebhookPath) |
Absolute URL sent to Mollie for payment callbacks, e.g. "https://yourdomain.com/cashier/webhook". Mollie requires an absolute HTTPS URL -- leaving this empty sends the relative WebhookPath instead, which fails once real payments start. |
FirstPaymentRedirectUrl |
string |
"/billing/success" |
URL to redirect after the first payment / mandate authorization |
GracePeriodDays |
int |
30 |
Number of days a cancelled subscription remains accessible |
BillingEngine |
string |
"MollieNative" |
Billing strategy: "MollieNative" or "Managed" |
ProcessingInterval |
TimeSpan |
"01:00:00" |
Interval between Managed engine processing runs |
OrderNumberFormat |
string |
"ORD-{0:D6}" |
Format string for generated order numbers |
PaymentMethodUpdateAmount |
decimal |
0.01 |
Amount for payment method update verification |
PaymentMethodUpdateRedirectUrl |
string |
"/billing/payment-method-updated" |
Redirect URL after payment method update |
Roadmap
CashierMollie v0.3.0 covers the full subscription lifecycle, dual billing engines, coupons, credits, refunds, charges, and more. The following features are planned for future releases:
- Subscription lifecycle -- create, cancel, resume, swap with grace periods and trials
- Webhook subscription activation -- activate pending subscriptions after successful first payment
- Dual billing engine -- MollieNative and Managed with background processing
- Coupon / discount system -- fixed and percentage-based discounts with configurable handlers
- Quantity management -- increment, decrement, and update subscription quantities
- Credit / balance system -- per-user account credit management
- One-off charges -- single payments with fluent builder
- Refund system -- partial and complete refunds via Mollie
- Payment method update -- flow for customers to change their payment method
- Mandate management -- check validity and revoke mandates
- Chargeback handling -- tracking and events for chargebacks
- Invoice interface -- pluggable IInvoiceGenerator for custom invoice generation
- Order model -- full order pipeline with order items
- Proration -- credit calculation for mid-cycle plan changes
- Invoice generation -- built-in HTML/PDF invoice renderer
- Plan pricing resolution -- automatic price lookup for managed billing engine
- Dunning / retry logic -- automatic payment retry with escalation
Contributing
Contributions are welcome! Here's how to get started:
- Open an issue first to discuss your idea or bug report
- Fork the repository and create a feature branch
- Write tests for any new functionality
- Follow Conventional Commits for commit messages
- Submit a pull request
# Clone and build
git clone https://github.com/TheOlymp/cashier-mollie-dotnet.git
cd cashier-mollie-dotnet
dotnet build
# Run tests
dotnet test
Credits
- Sander van Hooft and the Laravel/Mollie community -- for creating and maintaining laravel-cashier-mollie, the inspiration and reference implementation for this project
- Mollie -- for their excellent payment platform and API
- TheOlymp -- creator and maintainer of this .NET port
License
CashierMollie for .NET is open-sourced software licensed under the MIT License.
| 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
- Microsoft.EntityFrameworkCore (>= 10.0.3)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.3)
- Mollie.Api (>= 4.18.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.