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:

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

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.

| Event | Trigger | | ----- | ------- | | BalanceCreditedEto | Credit added (overpayment, promotional, manual) | | BalanceDebitedEto | Credit deducted (invoice, expiration) | | CreditExpiredEto | Promotional credit expired by background job |

| Method | Route | Permission | | ------ | ----- | ---------- | | GET | /api/{version}/customer-balance/balance?currency=EUR | CustomerBalance.Accounts.Read | | GET | /api/{version}/customer-balance/transactions?currency=EUR | CustomerBalance.Transactions.Read | | POST | /api/{version}/customer-balance/credit | CustomerBalance.Credits.Manage |

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 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>();

Granit.CustomerBalance.Notifications ships notifications keeping the party (end user) informed about the lifecycle of their balance — what was credited, what is about to expire, and what was depleted.

| Notification | Trigger | Channels | Severity | | ------------ | ------- | -------- | :------: | | customer-balance.credit_granted | BalanceCreditedEto — credit added (promotion, overpayment, manual adjustment) | Email, InApp | Info | | customer-balance.credit_expiring | CreditExpiringEto — credit approaching its expiration date (configurable lead time) | Email, InApp | Warning | | customer-balance.credit_expired | CreditExpiredEto — credit reached its expiration date and was reclaimed | Email, InApp | Warning | | customer-balance.depleted | BalanceDepletedEto — balance reached zero | Email, InApp | Info |

Email templates ship in EN + FR; additional cultures are produced via the translation script (US #1311).