SaaS credit system design is easy to underestimate. A founder may start with a single database update after a successful checkout, then discover months later that one webhook retry, refund, pricing change, or failed invoice job has made customer balances impossible to explain.

The core lesson from a recent discussion in r/SaaS is simple: payments, product credits, and invoices should be connected, but they should not be the same record or workflow. That distinction matters especially for AI tools, email platforms, API products, marketplaces, and other businesses where customers prepay for tokens, sends, generations, scans, seats, or usage allowances.

The deceptively simple credit purchase flow

At first, a credit-based product appears to have a linear transaction flow:

  1. A customer pays $50.
  2. The application adds 5,000 credits.
  3. The billing system creates an invoice or receipt.
  4. The customer spends credits using the product.

For an early-stage product with a few transactions a day, this may work even when all four actions happen in one request handler. A payment webhook arrives, a script changes a balance column, an invoice is created, and the support team handles exceptions manually.

The trouble is that these events are not one event. They are several events with different owners, failure modes, timing, and business meanings. A card processor can confirm payment while your invoicing provider is unavailable. Your application can grant credits while a later refund is issued. A webhook may be delivered more than once. A customer may have a legacy contract whose 1,000 credits were purchased at a very different rate from today’s list price.

Treating this as one atomic business action creates a system that looks clean in happy-path demos but becomes hard to trust under real operational conditions. The question is not whether a payment gateway integration works. The question is whether the business can reconstruct what happened when something does not work.

That was the practical warning in the original r/SaaS post: payment confirmation answers whether money was collected; a credit grant answers what value the customer can use in the product; and an invoice answers what accounting document must be produced. Those facts often occur close together, but they should be independently durable and recoverable.

Why payments, credits, and invoices are different domains

A reliable SaaS credit system design begins by assigning a clear meaning to each record. This is less about using a particular database or billing vendor than about refusing to overload one field or table with several incompatible responsibilities.

Payments are evidence of money movement

A payment record should answer questions such as:

  • What payment attempt occurred?
  • Which payment processor object or transaction ID identifies it?
  • How much was authorized, captured, paid, refunded, or disputed?
  • In which currency was it settled?
  • What is the processor’s current status?
  • Has this provider event already been processed by our application?

A successful payment is not automatically a customer entitlement. It is the financial event that may qualify a customer for an entitlement, depending on the commercial rules. For example, a company may deliberately delay high-risk account activation, require a completed bank transfer, or limit a promotional offer to one use per customer.

Credits are product entitlements

Credits, tokens, usage units, API calls, and prepaid balances are product-side entitlements. They answer a different question: what can this account consume right now, and why?

A credit record should be able to say that a customer received 10,000 image-generation credits because of a completed purchase, received 500 promotional credits from a campaign, consumed 12 credits for a completed request, or lost 1,000 unspent credits because a valid refund reversed the original purchase.

That distinction is crucial for products that sell metered services. A customer who bought credits for AI generations does not care whether your accounting PDF was generated two minutes later. They care that their balance is available after their payment is accepted.

Invoices are financial documents

An invoice is a commercial and accounting artifact. Depending on the business and jurisdiction, it may contain legal-entity details, tax IDs, invoice numbering, line items, tax calculations, payment terms, and references to a payment. It may be issued before payment, after payment, or on a periodic schedule.

An invoice should not be the only proof that the customer has been granted product value. If your invoice provider times out, a customer should not be forced to pay again or wait indefinitely for access to something they already bought. Instead, invoice generation can be a separate, retryable workflow with explicit status.

Separating these concerns creates more records, but it reduces ambiguity. It also makes each component easier to change. You can migrate an invoicing provider without rewriting your credit engine, update a pricing page without rewriting payment history, or add a second payment method without changing how product consumption works.

The ledger model: make movements the source of truth

The most valuable addition from the community response was the recommendation to treat the credit balance as a projection, not the source of truth. In practical terms, that means using an immutable entitlement ledger.

Instead of keeping only this:

  • customer_id
  • current_credit_balance

store individual movements such as:

  • credit grant from a completed payment
  • promotional credit grant
  • usage debit for an accepted request
  • expiration adjustment
  • refund reversal
  • chargeback reversal
  • support correction

The customer’s displayed balance is then calculated from those movements, either in real time or through a maintained projection. A fast balance column is still useful. It just should not be the only historical evidence available.

Why mutable balances become a support problem

Imagine a customer opens a ticket saying their account lost 4,000 credits. If the system only stores a current balance, support has to infer what happened from payment dashboards, logs, spreadsheets, and possibly a developer’s memory.

With a ledger, the answer is visible in a sequence of entries: a 5,000-credit grant on May 3, usage debits totaling 1,000 credits, then a 4,000-credit reversal tied to a refund on May 10. Whether the outcome is correct is a separate question; at least the system can explain its own behavior.

This matters for more than customer support. Finance teams need reconciliation. Engineering teams need safe retries. Product teams need to distinguish real consumption from promotional usage. Security teams need to investigate suspicious activity. A ledger becomes the shared source for all of those questions.

Use compensating entries rather than rewriting history

When something changes, add a new entry instead of silently editing or deleting the old one. A refund should create a reversal or adjustment linked to the original grant. A manual correction should have a reason, an actor, and a timestamp. An expired promotional balance should be a distinct expiration entry.

This approach preserves an audit trail and lets you answer a key operational question: what did the system believe at the time? It also prevents misleading historical reports. If an old credit purchase is edited to match a new price, you have not corrected data; you have replaced history.

An immutable ledger does not mean every entry is irreversible in a business sense. It means corrections are represented transparently. That is the same operational advantage accountants get from journal entries: the record shows both the original action and the correcting action.

Webhooks are at-least-once messages, not commands

One of the easiest ways to accidentally give away money is to assume a payment provider webhook will arrive exactly once, in perfect order, and only after every related object is available. Production integrations cannot safely make those assumptions.

Stripe’s webhook guidance explicitly tells developers to account for duplicate events and unordered delivery, while its API documentation supports idempotent requests for safely retrying create operations. Those patterns are not Stripe-specific quirks; they are normal realities of distributed systems.

A webhook should therefore be interpreted as a notification that your system needs to evaluate, not as an unconditional command to add credits.

The unsafe pattern

The fragile version often looks like this:

  1. Receive a payment-success callback.
  2. Add 1,000 credits to the customer’s balance.
  3. Return a successful response.

If the provider delivers the same event again because of a network timeout, deployment interruption, or retry policy, the customer receives another 1,000 credits. If two workers process it concurrently, the problem can happen even faster.

The safer pattern

A more robust flow is:

  1. Verify the provider signature.
  2. Store the event ID or the relevant payment transaction ID in a durable processed-events table.
  3. Enforce a database uniqueness constraint on the idempotency key.
  4. Retrieve or validate the canonical payment state if necessary.
  5. Create one credit-grant ledger entry tied to that payment.
  6. Commit the grant and idempotency record together where possible.
  7. Acknowledge the webhook only after durable processing or durable queuing.

The exact key depends on the provider and event semantics. In some cases the event ID is appropriate; in others, the payment intent, charge, order, or internal purchase ID is the true business key. The important point is to decide what economic event may create an entitlement, then guarantee that it creates it no more than once.

A unique index is not optional defensive polish here. Application-level checks such as if not processed then add credits are vulnerable to race conditions when two workers evaluate the condition simultaneously. The database must help enforce the rule.

Design an idempotent purchase workflow

Idempotency means repeating an operation produces the same intended result rather than repeating the economic effect. It is the foundation of retry-safe billing systems.

For credit purchases, there are two separate places to apply it: the outbound request you make to a payment provider and the inbound event processing you perform after the provider responds.

Idempotency for checkout creation

Suppose a user double-clicks the buy button, a mobile connection fails after the request leaves the device, or your own frontend retries following a timeout. Your backend should create an internal purchase order first, assign it an idempotency key, and reuse that key while creating the external checkout or payment object.

This prevents a single user intent from becoming multiple payment attempts and multiple orders. It also gives support a stable internal reference that persists even if payment-provider object names or APIs change later.

Idempotency for fulfillment

Payment completion is fulfillment input, not fulfillment itself. When the completed payment event is processed, create a credit grant whose source reference is unique, such as payment:processor_charge_id or purchase:internal_order_id.

A practical schema might include:

  • purchase_id for the customer’s commercial order
  • payment_id for the processor-side transaction
  • ledger_entry_id for the entitlement movement
  • source_type and source_id for traceability
  • idempotency_key for retry protection
  • status for pending, completed, failed, reversed, or reconciled states

Do not use an arbitrary timestamp as the only deduplication mechanism. Timestamps are useful evidence, but they do not identify the same economic event across retries.

Idempotency does not eliminate reconciliation

Even good event handling can encounter edge cases: an outage after a processor accepts payment but before your system records fulfillment, an internal database incident, or an integration bug that affected a subset of events. This is why every credit product needs a reconciliation process.

At minimum, periodically compare completed payment records against credit grants. Look for paid purchases with no grant, grants with no eligible payment, duplicate grants for the same source, refunds lacking reversals, and negative balances created by unusual timing.

A daily reconciliation job is often enough initially. At higher volume or higher transaction value, teams may run near-real-time checks and alert on mismatches. The objective is not to pretend errors can never happen. It is to ensure they become detectable, explainable, and repairable.

Invoicing must not block entitlement delivery

Many founders initially link invoice creation to fulfillment because both happen after a successful purchase. That is understandable, but it can create an unnecessary coupling: invoice creation fails, so credits are not granted; or credits are granted only after an external tax or accounting service responds.

A better design uses separate states:

  • Payment state: pending, paid, failed, refunded, disputed
  • Entitlement state: pending grant, active, partially consumed, reversed, expired
  • Invoice state: not required, queued, issued, delivery failed, voided, corrected

These states can be related by identifiers without being forced into a single all-or-nothing action. For example, a successful payment can activate an entitlement immediately, while an asynchronous invoice job creates and emails the document afterward. If that job fails, it retries without charging the customer again or granting another credit bundle.

This separation is also useful for subscription products. Invoice lifecycle events, payment collection, and actual product access may not align perfectly. A business could offer a grace period after a failed renewal, restrict certain features while a payment is pending, or preserve prepaid credits after a subscription is cancelled. Those are commercial policy decisions, not accidents imposed by a billing vendor’s object model.

For SaaS teams selling communications or API capacity, this avoids a common support nightmare: a customer has a valid paid record but cannot send because a PDF or tax calculation failed in another system. If credits represent email capacity, publish the commercial rules clearly alongside transactional email pricing, but enforce the entitlement independently from the paperwork workflow.

Preserve the price that was true at the time of sale

Credits are not inherently money. They are an internal unit of value defined by your product. That unit may change over time.

A customer might buy 100,000 credits under a launch promotion, receive a regional price, use a reseller portal, have a negotiated enterprise rate, or be grandfathered onto an old plan. A new pricing model might later make the same 100,000 credits worth more or less in cash terms.

For that reason, do not derive historical revenue by multiplying old credit grants by today’s price. Store the commercial snapshot associated with the purchase.

What to snapshot on a credit purchase

A useful purchase record generally captures:

  • currency and amount charged
  • taxes, discounts, and fees where relevant
  • credit quantity granted
  • price or product version
  • plan, catalog item, or offer ID
  • jurisdiction or tax context where applicable
  • exchange rate if cross-currency reporting is involved
  • terms such as expiry, refundability, and promotional restrictions

You may later normalize or improve catalog data, but the original transaction snapshot should remain intact. This protects reporting integrity and lets support explain why two similar-looking customers paid different prices.

Historical price snapshots also make experimentation safer. Product teams can test bundles, volume discounts, and annual prepayment options without corrupting the meaning of prior revenue records. Finance can analyze realized revenue by cohort, while product can analyze credit consumption separately.

Refunds, disputes, and chargebacks need explicit rules

Refunds are where many otherwise sensible credit systems become inconsistent. The payment provider shows money returned, but the product balance remains untouched. Or the balance is simply overwritten, wiping out evidence of what the customer used before the refund.

Start by defining policies before automating them. A refund policy for credits is not merely a payment setting. It is a set of entitlement rules.

Questions founders should decide in advance

  • Can unused purchased credits be refunded?
  • Can partially used credit packs be refunded, and on what basis?
  • What happens when credits were consumed before a chargeback?
  • Can a balance go negative after a reversal?
  • Does a refund revoke access immediately or at the end of a service period?
  • Who can issue manual credit adjustments, and what approval is required?

The implementation should represent the answer as ledger movements. If a $100 purchase granted 10,000 credits and is fully refunded before use, create a linked reversal of 10,000 credits. If only 4,000 credits remain, the business may remove those 4,000 and record an outstanding exposure for the rest, depending on its policy.

A chargeback may require different treatment from a voluntary refund because it can arrive later and carries a dispute process. The key architectural point remains the same: payment status changes should trigger explicit entitlement decisions and traceable adjustments, not silent edits to a balance field.

Stripe documents refund and dispute workflows as separate concepts, which reinforces the broader design principle: payment reversals have their own lifecycle. Your internal credit engine should be prepared to consume those lifecycle changes without assuming a purchase is permanently final.

Build for failures between services

The hardest billing bugs often occur in the gaps between successful steps. A database commit succeeds but a queue publish fails. A queue job runs but the process crashes before acknowledging it. An invoice API returns a timeout even though it created the document. A webhook is received while the customer record is temporarily locked.

The remedy is not a giant transaction across every third-party service; that is usually impossible. The remedy is to build explicit recovery paths.

Use durable jobs and an outbox pattern

When a local transaction needs to trigger external work, store an outbox record in the same database transaction as the business change. A worker can later send the invoice request, email notification, analytics event, or CRM update. If it fails, retry it with a stable idempotency key.

For example, the transaction that creates a credit grant may also write an invoice-needed outbox message. The customer gets their credits once the grant is committed. Invoice creation happens asynchronously and can be retried independently until it succeeds or is escalated.

Make states visible rather than implicit

Avoid hiding a multi-stage process behind a vague boolean such as paid. A purchase can be paid but fulfillment pending; fulfilled but invoice queued; refunded but entitlement reversal pending; or disputed while service access is under review.

Explicit state machines improve both observability and user experience. Internally, they make dashboards meaningful. Externally, they let you show a customer an accurate status such as payment received, credits available, receipt being prepared instead of an alarming generic error.

Do not retry blindly

Retries should have a reason, limit, backoff strategy, and operator visibility. A retry that creates a new external invoice every time it sees a timeout is not resilience. It is a duplicate-document generator.

Store the external object ID as soon as it is known. When a response is ambiguous, query the provider by idempotency key, purchase reference, or metadata before creating another object. Safe retries require stable identities.

Observability turns a billing flow into an operable system

Traceability was the central theme of the original post, and it deserves emphasis. Automation without visibility just produces failures faster.

A support agent, finance lead, or engineer should be able to search a customer and see a coherent timeline:

  1. Purchase created.
  2. Checkout initiated.
  3. Processor payment completed.
  4. Credit grant posted.
  5. Credits consumed by specific product actions.
  6. Invoice issued or queued.
  7. Refund, dispute, or adjustment processed if applicable.

Each event should link to the related internal and external identifiers. This avoids the all-too-common situation where payment information lives in one dashboard, entitlement information lives in a production database, invoices live in another vendor, and the only connection is a person who knows how the system evolved.

Metrics worth monitoring

Track operational metrics, not just gross revenue:

  • paid purchases with no credit grant
  • credit grants with no qualifying payment
  • duplicate-event rejection count
  • invoice jobs pending beyond a service-level threshold
  • refund events without entitlement adjustments
  • manual adjustment volume and reasons
  • negative balances and accounts blocked for reversal
  • age of unresolved reconciliation exceptions

Spikes in these metrics often expose integration regressions before customers report them. They also reveal whether a process that appears automated is quietly relying on manual cleanup.

A practical architecture for early-stage SaaS teams

Founders do not need a bank-grade ledger platform on day one. But they do need a minimum design that remains understandable as transaction volume grows.

A pragmatic first version can include five durable components:

  1. Purchase order table: Records customer intent, offer snapshot, amount, currency, and lifecycle status.
  2. Payment table: Stores provider transaction references, processor states, raw event references, and settlement data.
  3. Credit ledger table: Stores immutable grants, debits, reversals, and adjustments with source links.
  4. Balance projection: Stores or caches current available credits for fast product authorization.
  5. Job and event tables: Store processed webhooks, invoice tasks, retries, and reconciliation exceptions.

The critical database constraints are often more important than the framework:

  • unique provider event IDs where appropriate
  • unique credit-grant source references
  • foreign keys or durable associations among purchases, payments, and ledger entries
  • append-only permissions for normal ledger operations
  • audit fields for every manual adjustment

A product team can begin with a relational database and a background job queue. The architecture becomes more sophisticated only when scale, regulatory needs, or organizational complexity demand it. The mistake is not starting simple; the mistake is starting with no durable event history and no way to recover from a partial failure.

When to fix a fragile payment and credit setup

The right moment is earlier than most teams think. You do not need thousands of customers to have meaningful financial risk. A few high-value accounts, a paid API launch, or a growing self-serve motion can make a bad edge case expensive enough to damage trust.

Prioritize an architectural upgrade when any of these are true:

  • your team manually fixes balances more than occasionally
  • customers can buy multiple credit bundles or use promotions
  • you support refunds, chargebacks, or negotiated contracts
  • more than one service writes to a customer balance
  • invoice generation relies on a separate system
  • you cannot explain a balance change from your own records
  • developers are afraid to replay a webhook or retry a failed job

The final point is especially revealing. If replaying an event feels dangerous, the workflow is probably not idempotent enough. If a failed job cannot be retried without a human deciding whether it might duplicate money, credits, or invoices, the system is carrying hidden operational debt.

The strategic benefit: trust scales better than manual repair

A strong SaaS credit system design is not just infrastructure hygiene. It supports better product and commercial decisions.

With a trustworthy entitlement ledger, you can introduce usage-based plans, add promotional bundles, run partner programs, offer refunds with clear policies, and experiment with pricing while preserving historical truth. With linked payment and invoice records, support can resolve tickets quickly and finance can reconcile revenue without waiting for engineering queries.

The original r/SaaS discussion was right to frame this as a traceability problem, not merely an automation problem. Customers rarely see your internal architecture, but they immediately notice when a paid balance is missing, a refund does not change access, or a support agent cannot explain what happened.

Build the system so every money-related event has a stable identity, every entitlement movement has a reason, every external side effect can be retried safely, and every correction leaves a visible trail. That is how a credit product stays reliable when its volume, pricing complexity, and customer expectations grow.

FAQ

What is the best database model for SaaS credits?

For most products, use an append-only credit ledger as the source of truth and maintain a separate current-balance projection for fast authorization. Each grant, usage debit, refund reversal, expiration, and manual adjustment should be a distinct movement linked to its business source.

Should a successful payment immediately add credits?

Usually, yes, if your commercial policy considers the payment complete and the fraud risk is acceptable. But the credit grant should be its own idempotent transaction tied to the payment or purchase identifier, not an unprotected side effect of receiving a webhook.

How do I prevent duplicate credit grants from webhooks?

Verify webhook signatures, record a stable provider event or transaction identifier, and enforce a database uniqueness constraint before creating the grant. Also create a unique source reference on the ledger entry itself, so duplicate deliveries cannot create another entitlement movement.

Should refunds edit the original credit purchase record?

No. Preserve the original grant and create a linked compensating ledger entry for the reversal or adjustment. This provides an audit trail and makes it possible to explain the current balance months later.

Can invoices and credit delivery be processed separately?

Yes, and they generally should be. Payment confirmation, entitlement fulfillment, and invoice creation have different failure modes. Grant the customer’s valid entitlement based on payment policy, then queue invoice generation as a separately retryable workflow with its own state and monitoring.