Customer Balance — Credit Ledger
Granit.CustomerBalance is a credit ledger — not a payment method or e-money system.
It manages accounting credits (promotional, overpayment surplus, manual adjustments)
that are applied before PSP charges. The module intercepts the billing flow between
Invoicing and Payments via a strategy pattern (IInvoicePrePaymentProcessor).
Package structure
Section titled “Package structure”DirectoryGranit.CustomerBalance/ Domain: BalanceAccount, BalanceTransaction, events, CQRS interfaces
- Granit.CustomerBalance.EntityFrameworkCore Isolated DbContext, append-only store
- Granit.CustomerBalance.Endpoints Balance inquiry, transaction history, admin credit
- Granit.CustomerBalance Billing choreography (deduction + overpayment)
- Granit.CustomerBalance.BackgroundJobs Credit expiration scan
Domain model
Section titled “Domain model”BalanceAccount (AggregateRoot)
Section titled “BalanceAccount (AggregateRoot)”Per-tenant, per-currency credit balance with cached running total:
- Composite business key:
(TenantId, Currency)— unique index - Balance: cached running total, protected by
IConcurrencyAware(optimistic concurrency) - Transactions: append-only
BalanceTransactionchild entities (ISO 27001 audit trail) - Credit(): adds credit, updates balance, raises
BalanceCreditedEto - Debit(): deducts credit, updates balance, raises
BalanceDebitedEto
BalanceTransaction (Entity, append-only)
Section titled “BalanceTransaction (Entity, append-only)”Immutable ledger entry with generic document linking:
| Property | Description |
|---|---|
Type | Credit or Debit |
Amount | Always positive (direction from Type) |
Source | Promotional, Overpayment, ManualAdjustment, InvoiceDeduction, RefundCredit, Expiration |
ReferenceId | Optional external document (invoice, refund, etc.) |
ReferenceType | Type of referenced document ("Invoice", "Refund", etc.) |
ExpiresAt | Expiration date for promotional credits |
Billing flow integration
Section titled “Billing flow integration”The module uses a strategy pattern to intercept the billing flow — no cascading events, no race conditions:
InvoiceFinalizedEto │ ▼AutoChargeOnInvoiceHandler (Payments.Wolverine) │ ├── Delegates to IInvoicePrePaymentProcessor │ ├── Without CustomerBalance: PassThroughPrePaymentProcessor (full amount) │ └── With CustomerBalance: CustomerBalancePrePaymentProcessor (deducts credit) │ ▼InitiatePaymentCommand (remaining amount) → PSPOpt-in architecture
Section titled “Opt-in architecture”- Without CustomerBalance installed:
TryAddScopedregisters the pass-through. PSP charges full amount. - With CustomerBalance installed:
services.Replace()swaps the processor. Credit deducted first.
Idempotency
Section titled “Idempotency”Debit operations use ReferenceId as a deduplication key. If Wolverine retries after a
partial failure (wallet debited, credit note not yet applied), the retry detects the
existing deduction and skips the debit — only applies the credit note.
Integration events
Section titled “Integration events”| Event | Trigger |
|---|---|
BalanceCreditedEto | Credit added (overpayment, promotional, manual) |
BalanceDebitedEto | Credit deducted (invoice, expiration) |
CreditExpiredEto | Promotional credit expired by background job |
API endpoints
Section titled “API endpoints”| Method | Route | Permission |
|---|---|---|
| GET | /api/granit/customer-balance/balance?currency=EUR | CustomerBalance.Accounts.Read |
| GET | /api/granit/customer-balance/transactions?currency=EUR | CustomerBalance.Transactions.Read |
| POST | /api/granit/customer-balance/credit | CustomerBalance.Credits.Manage |
Domain services
Section titled “Domain services”Business logic is encapsulated in domain services, keeping Wolverine/BackgroundJob handlers as thin pass-through delegates.
| Interface | Responsibility | Package |
|---|---|---|
ICreditExpirationService | Scans expired promotional credits, debits balances, publishes CreditExpiredEto | Granit.CustomerBalance |
IOverpaymentCreditService | Credits overpayment surplus to a tenant’s balance account (creates account if needed) | Granit.CustomerBalance |
// ICreditExpirationService — called by CreditExpirationScanHandlerpublic interface ICreditExpirationService{ Task<int> ExpireCreditsAsync(CancellationToken cancellationToken = default);}
// IOverpaymentCreditService — called by OverpaymentCreditHandlerpublic interface IOverpaymentCreditService{ Task CreditOverpaymentAsync( Guid tenantId, string currency, decimal amount, Guid invoiceId, CancellationToken cancellationToken = default);}Both services are registered automatically by AddGranitCustomerBalance() and
can be replaced via TryAddTransient for testing or custom implementations.
Quick start
Section titled “Quick start”// Core + persistencebuilder.AddGranitCustomerBalance();builder.AddGranitCustomerBalanceEntityFrameworkCore(options => ...);
// Billing integration (optional — requires Invoicing + Wolverine)builder.AddModule<GranitCustomerBalanceWolverineModule>();
// Credit expiration (optional)builder.AddModule<GranitCustomerBalanceBackgroundJobsModule>();