Skip to content

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).

  • 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

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 BalanceTransaction child entities (ISO 27001 audit trail)
  • Credit(): adds credit, updates balance, raises BalanceCreditedEto
  • Debit(): deducts credit, updates balance, raises BalanceDebitedEto

Immutable ledger entry with generic document linking:

PropertyDescription
TypeCredit or Debit
AmountAlways positive (direction from Type)
SourcePromotional, Overpayment, ManualAdjustment, InvoiceDeduction, RefundCredit, Expiration
ReferenceIdOptional external document (invoice, refund, etc.)
ReferenceTypeType of referenced document ("Invoice", "Refund", etc.)
ExpiresAtExpiration date for promotional credits

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) → PSP
  • Without CustomerBalance installed: TryAddScoped registers the pass-through. PSP charges full amount.
  • With CustomerBalance installed: services.Replace() swaps the processor. Credit deducted first.

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.

EventTrigger
BalanceCreditedEtoCredit added (overpayment, promotional, manual)
BalanceDebitedEtoCredit deducted (invoice, expiration)
CreditExpiredEtoPromotional credit expired by background job
MethodRoutePermission
GET/api/granit/customer-balance/balance?currency=EURCustomerBalance.Accounts.Read
GET/api/granit/customer-balance/transactions?currency=EURCustomerBalance.Transactions.Read
POST/api/granit/customer-balance/creditCustomerBalance.Credits.Manage

Business logic is encapsulated in domain services, keeping Wolverine/BackgroundJob handlers as thin pass-through delegates.

InterfaceResponsibilityPackage
ICreditExpirationServiceScans expired promotional credits, debits balances, publishes CreditExpiredEtoGranit.CustomerBalance
IOverpaymentCreditServiceCredits overpayment surplus to a tenant’s balance account (creates account if needed)Granit.CustomerBalance
// ICreditExpirationService — called by CreditExpirationScanHandler
public interface ICreditExpirationService
{
Task<int> ExpireCreditsAsync(CancellationToken cancellationToken = default);
}
// IOverpaymentCreditService — called by OverpaymentCreditHandler
public 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.

// Core + persistence
builder.AddGranitCustomerBalance();
builder.AddGranitCustomerBalanceEntityFrameworkCore(options => ...);
// Billing integration (optional — requires Invoicing + Wolverine)
builder.AddModule<GranitCustomerBalanceWolverineModule>();
// Credit expiration (optional)
builder.AddModule<GranitCustomerBalanceBackgroundJobsModule>();