Ledger

Coupons

Percentage and fixed-amount discounts with redemption tracking and validity windows.

Coupons provide discount mechanisms for subscriptions and invoices. Ledger supports both percentage-based and fixed-amount discounts with configurable validity windows, redemption limits, and per-app scoping.

Structure

type Coupon struct {
    types.Entity
    ID             id.CouponID       `json:"id"`
    Code           string            `json:"code"`
    Name           string            `json:"name"`
    Type           CouponType        `json:"type"`
    Amount         types.Money       `json:"amount,omitempty"`
    Percentage     int               `json:"percentage,omitempty"`
    Currency       string            `json:"currency"`
    MaxRedemptions int               `json:"max_redemptions"`
    TimesRedeemed  int               `json:"times_redeemed"`
    ValidFrom      *time.Time        `json:"valid_from,omitempty"`
    ValidUntil     *time.Time        `json:"valid_until,omitempty"`
    AppID          string            `json:"app_id"`
    Metadata       map[string]string `json:"metadata,omitempty"`
}

Coupon types

TypeDescriptionExample
CouponTypePercentagePercentage discount off subtotal20% off
CouponTypeAmountFixed amount discount$10 off
type CouponType string

const (
    CouponTypePercentage CouponType = "percentage"
    CouponTypeAmount     CouponType = "amount"
)

Creating coupons

Percentage discount

coupon := &coupon.Coupon{
    ID:             id.NewCouponID(),
    Code:           "SUMMER20",
    Name:           "Summer Sale - 20% Off",
    Type:           coupon.CouponTypePercentage,
    Percentage:     20,
    MaxRedemptions: 100,
    ValidFrom:      ptrTime(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)),
    ValidUntil:     ptrTime(time.Date(2024, 9, 1, 0, 0, 0, 0, time.UTC)),
    AppID:          "myapp",
}

if err := store.CreateCoupon(ctx, coupon); err != nil {
    log.Fatal(err)
}

Fixed amount discount

coupon := &coupon.Coupon{
    ID:             id.NewCouponID(),
    Code:           "SAVE10",
    Name:           "$10 Off First Month",
    Type:           coupon.CouponTypeAmount,
    Amount:         types.USD(1000), // $10.00
    Currency:       "USD",
    MaxRedemptions: 500,
    AppID:          "myapp",
}

Validity windows

Coupons can have optional start and end dates:

// Always valid (no window)
coupon.ValidFrom = nil
coupon.ValidUntil = nil

// Valid for a specific period
coupon.ValidFrom = ptrTime(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
coupon.ValidUntil = ptrTime(time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC))

// Valid starting from a date (no end)
coupon.ValidFrom = ptrTime(time.Now())
coupon.ValidUntil = nil

When validating a coupon:

now := time.Now()

if coupon.ValidFrom != nil && now.Before(*coupon.ValidFrom) {
    return ErrCouponNotStarted
}

if coupon.ValidUntil != nil && now.After(*coupon.ValidUntil) {
    return ErrCouponExpired
}

Redemption tracking

Each coupon tracks how many times it has been used:

// Check if coupon is still available
if coupon.MaxRedemptions > 0 && coupon.TimesRedeemed >= coupon.MaxRedemptions {
    return ErrCouponExhausted
}

// Redeem the coupon
coupon.TimesRedeemed++
store.UpdateCoupon(ctx, coupon)

Set MaxRedemptions to 0 for unlimited redemptions.

Applying coupons to invoices

When generating an invoice, apply the coupon discount:

func applyCoupon(invoice *invoice.Invoice, cpn *coupon.Coupon) {
    var discount types.Money

    switch cpn.Type {
    case coupon.CouponTypePercentage:
        discount = invoice.Subtotal.Multiply(int64(cpn.Percentage)).Divide(100)
    case coupon.CouponTypeAmount:
        discount = cpn.Amount
        if discount.GreaterThan(invoice.Subtotal) {
            discount = invoice.Subtotal // Don't discount more than subtotal
        }
    }

    invoice.LineItems = append(invoice.LineItems, invoice.LineItem{
        ID:          id.NewLineItemID(),
        InvoiceID:   invoice.ID,
        Type:        invoice.LineItemDiscount,
        Description: fmt.Sprintf("Coupon: %s", cpn.Code),
        Amount:      discount.Negate(),
    })

    invoice.DiscountAmount = discount
    invoice.Total = invoice.Subtotal.Add(invoice.TaxAmount).Subtract(discount)
}

Coupon validation

Use the CouponValidator plugin interface for custom validation logic:

type CouponValidator interface {
    plugin.Plugin
    ValidateCoupon(ctx context.Context, coupon interface{}, sub interface{}) error
}

Example: Limit coupons to new customers only:

type NewCustomerOnly struct{}

func (v *NewCustomerOnly) Name() string { return "new-customer-coupon-validator" }

func (v *NewCustomerOnly) ValidateCoupon(ctx context.Context, cpn interface{}, sub interface{}) error {
    subscription := sub.(*subscription.Subscription)

    // Check if customer already had a subscription
    existing, _ := store.ListSubscriptions(ctx, subscription.TenantID, subscription.AppID, subscription.ListOpts{})
    if len(existing) > 0 {
        return fmt.Errorf("coupon only valid for new customers")
    }

    return nil
}

Listing coupons

coupons, err := store.ListCoupons(ctx, "myapp", coupon.ListOpts{
    Active: true,  // Only active coupons
    Limit:  20,
    Offset: 0,
})

for _, cpn := range coupons {
    fmt.Printf("%s (%s): %d/%d redeemed\n",
        cpn.Code, cpn.Name, cpn.TimesRedeemed, cpn.MaxRedemptions)
}

Store interface

type Store interface {
    Create(ctx context.Context, c *Coupon) error
    Get(ctx context.Context, code string, appID string) (*Coupon, error)
    GetByID(ctx context.Context, couponID id.CouponID) (*Coupon, error)
    List(ctx context.Context, appID string, opts ListOpts) ([]*Coupon, error)
    Update(ctx context.Context, c *Coupon) error
    Delete(ctx context.Context, couponID id.CouponID) error
}

type ListOpts struct {
    Active bool
    Limit  int
    Offset int
}

API routes

MethodPathDescription
POST/ledger/couponsCreate a coupon
GET/ledger/couponsList coupons
GET/ledger/coupons/{code}Get coupon by code
GET/ledger/coupons/id/{id}Get coupon by ID
PUT/ledger/coupons/{id}Update a coupon
DELETE/ledger/coupons/{id}Delete a coupon
POST/ledger/coupons/{code}/redeemRedeem a coupon

On this page