mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
The billing cycle takes one step per live subscription (engine.Settle) and reports every one: org, plan, interval, paid period, billed period, price, amount, source and action. Without dryRun=false it writes nothing. - A renewal bills the period after the paid one from sub.Plan, the plan as bought, on the source it was bought from: its card, or prepaid money (credits, then the balance) for a plan bought with credits or the balance. A plan collected outside Hanzo is renewed by its processor and never charged. - A decline is retried 1, 3 and 7 days after it; the subscriber keeps the tier meanwhile, and the last decline ends the subscription. - A subscription more than 72 hours past its paid period with no attempt is overdue: nothing is charged or written until its customer renews it, which bills the period from then and never the time it was not renewed. - Realignment is an operator's act with its own dry run (POST /v1/billing/realign/run[-all]): a row whose PeriodStart is its current paid invoice's PeriodEnd moves back onto that invoice's period. - Every payment on an invoice holds one lock and records its attempt before asking for money; an unknown outcome is repeated under its key, escalated to unpaid after the schedule, and resolved before any cancel or end voids it. - A cancel landing while a renewal is at the processor stays canceled; a charge that does not answer in 30 seconds is unknown; one org's cycle stops starting subscriptions after 10 minutes. - A voided or uncollectible invoice returns what it collected to prepaid money; tax is set on drafts only; a platform operator voids an unresolved attempt through POST /v1/billing/invoices/:id/void-unresolved with a reason recorded in the billing event ledger. - How a subscription was bought is read from its earliest invoice that bills a period. Co-authored-by: Hanzo Dev <[email protected]>
284 lines
7.9 KiB
Go
284 lines
7.9 KiB
Go
package billing
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/billing/engine"
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/middleware"
|
|
"github.com/hanzoai/commerce/models/billinginvoice"
|
|
"github.com/hanzoai/commerce/types"
|
|
"github.com/hanzoai/commerce/util/json/http"
|
|
)
|
|
|
|
type addLineItemRequest struct {
|
|
Description string `json:"description"`
|
|
Amount int64 `json:"amount"` // cents
|
|
Quantity int64 `json:"quantity"`
|
|
MeterId string `json:"meterId,omitempty"`
|
|
PlanId string `json:"planId,omitempty"`
|
|
UnitPrice int64 `json:"unitPrice,omitempty"`
|
|
}
|
|
|
|
// AddInvoiceLineItem appends a line item to a draft invoice and recalculates
|
|
// the subtotal.
|
|
//
|
|
// POST /v1/billing/invoices/:id/line-items
|
|
func AddInvoiceLineItem(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
id := c.Param("id")
|
|
inv := billinginvoice.New(db)
|
|
if err := inv.GetById(id); err != nil {
|
|
return http.Fail(c, 404, "invoice not found", err)
|
|
}
|
|
|
|
if inv.Status != billinginvoice.Draft {
|
|
return http.Fail(c, 400, "can only add line items to draft invoices", nil)
|
|
}
|
|
|
|
var req addLineItemRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return http.Fail(c, 400, "invalid request body", err)
|
|
}
|
|
|
|
if req.Description == "" {
|
|
return http.Fail(c, 400, "description is required", nil)
|
|
}
|
|
|
|
// Compute amount from quantity * unitPrice if amount not provided
|
|
amount := req.Amount
|
|
if amount == 0 && req.Quantity > 0 && req.UnitPrice > 0 {
|
|
amount = req.Quantity * req.UnitPrice
|
|
}
|
|
|
|
lineItem := billinginvoice.LineItem{
|
|
Id: fmt.Sprintf("li_%s_%d", id, len(inv.LineItems)+1),
|
|
Type: billinginvoice.LineOneOff,
|
|
Description: req.Description,
|
|
Amount: amount,
|
|
Quantity: req.Quantity,
|
|
UnitPrice: req.UnitPrice,
|
|
Currency: inv.Currency,
|
|
}
|
|
|
|
if req.MeterId != "" {
|
|
lineItem.Type = billinginvoice.LineUsage
|
|
lineItem.MeterId = req.MeterId
|
|
}
|
|
if req.PlanId != "" {
|
|
lineItem.Type = billinginvoice.LineSubscription
|
|
lineItem.PlanId = req.PlanId
|
|
}
|
|
|
|
inv.LineItems = append(inv.LineItems, lineItem)
|
|
inv.RecalculateSubtotal()
|
|
|
|
// Recalculate AmountDue for draft: subtotal + tax - discount - credit
|
|
inv.AmountDue = inv.Subtotal + inv.Tax - inv.Discount - inv.CreditApplied
|
|
if inv.AmountDue < 0 {
|
|
inv.AmountDue = 0
|
|
}
|
|
|
|
if err := inv.Update(); err != nil {
|
|
log.Error("Failed to add line item: %v", err, c)
|
|
return http.Fail(c, 500, "failed to update invoice", err)
|
|
}
|
|
|
|
return c.JSON(200, invoiceResponse(inv))
|
|
}
|
|
|
|
// RemoveInvoiceLineItem removes a line item from a draft invoice by index
|
|
// or line item ID.
|
|
//
|
|
// DELETE /v1/billing/invoices/:id/line-items/:itemId
|
|
func RemoveInvoiceLineItem(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
id := c.Param("id")
|
|
inv := billinginvoice.New(db)
|
|
if err := inv.GetById(id); err != nil {
|
|
return http.Fail(c, 404, "invoice not found", err)
|
|
}
|
|
|
|
if inv.Status != billinginvoice.Draft {
|
|
return http.Fail(c, 400, "can only remove line items from draft invoices", nil)
|
|
}
|
|
|
|
itemId := c.Param("itemId")
|
|
if itemId == "" {
|
|
return http.Fail(c, 400, "itemId is required", nil)
|
|
}
|
|
|
|
// Find and remove by line item ID
|
|
found := false
|
|
updated := make([]billinginvoice.LineItem, 0, len(inv.LineItems))
|
|
for _, li := range inv.LineItems {
|
|
if li.Id == itemId {
|
|
found = true
|
|
continue
|
|
}
|
|
updated = append(updated, li)
|
|
}
|
|
|
|
if !found {
|
|
return http.Fail(c, 404, "line item not found", nil)
|
|
}
|
|
|
|
inv.LineItems = updated
|
|
inv.RecalculateSubtotal()
|
|
|
|
// Recalculate AmountDue
|
|
inv.AmountDue = inv.Subtotal + inv.Tax - inv.Discount - inv.CreditApplied
|
|
if inv.AmountDue < 0 {
|
|
inv.AmountDue = 0
|
|
}
|
|
|
|
if err := inv.Update(); err != nil {
|
|
log.Error("Failed to remove line item: %v", err, c)
|
|
return http.Fail(c, 500, "failed to update invoice", err)
|
|
}
|
|
|
|
return c.JSON(200, invoiceResponse(inv))
|
|
}
|
|
|
|
type applyDiscountRequest struct {
|
|
CouponId string `json:"couponId,omitempty"`
|
|
DiscountAmount int64 `json:"discountAmount,omitempty"` // cents
|
|
DiscountName string `json:"discountName,omitempty"`
|
|
}
|
|
|
|
// ApplyInvoiceDiscount applies a discount to a draft invoice and recalculates
|
|
// the amount due.
|
|
//
|
|
// POST /v1/billing/invoices/:id/apply-discount
|
|
func ApplyInvoiceDiscount(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
id := c.Param("id")
|
|
inv := billinginvoice.New(db)
|
|
if err := inv.GetById(id); err != nil {
|
|
return http.Fail(c, 404, "invoice not found", err)
|
|
}
|
|
|
|
if inv.Status != billinginvoice.Draft {
|
|
return http.Fail(c, 400, "can only apply discounts to draft invoices", nil)
|
|
}
|
|
|
|
var req applyDiscountRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return http.Fail(c, 400, "invalid request body", err)
|
|
}
|
|
|
|
if req.DiscountAmount <= 0 && req.CouponId == "" {
|
|
return http.Fail(c, 400, "discountAmount or couponId is required", nil)
|
|
}
|
|
|
|
// If couponId is provided, look up the coupon amount.
|
|
// For now, use the explicit discountAmount. Coupon lookup can be
|
|
// added when a coupon model is wired in.
|
|
discount := req.DiscountAmount
|
|
name := req.DiscountName
|
|
|
|
if req.CouponId != "" && name == "" {
|
|
name = fmt.Sprintf("coupon:%s", req.CouponId)
|
|
}
|
|
|
|
// Cap discount at subtotal so we never go negative
|
|
if discount > inv.Subtotal {
|
|
discount = inv.Subtotal
|
|
}
|
|
|
|
inv.Discount = discount
|
|
inv.DiscountName = name
|
|
|
|
// Recalculate AmountDue
|
|
inv.AmountDue = inv.Subtotal + inv.Tax - inv.Discount - inv.CreditApplied
|
|
if inv.AmountDue < 0 {
|
|
inv.AmountDue = 0
|
|
}
|
|
|
|
if err := inv.Update(); err != nil {
|
|
log.Error("Failed to apply discount: %v", err, c)
|
|
return http.Fail(c, 500, "failed to update invoice", err)
|
|
}
|
|
|
|
return c.JSON(200, invoiceResponse(inv))
|
|
}
|
|
|
|
// CalculateInvoiceTax computes tax for an invoice based on a customer address
|
|
// and updates the invoice with the resulting tax lines.
|
|
//
|
|
// POST /v1/billing/invoices/:id/calculate-tax?country=...&state=...
|
|
func CalculateInvoiceTax(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
id := c.Param("id")
|
|
release, err := engine.LockInvoice(db, id)
|
|
if errors.Is(err, engine.ErrInvoiceBusy) {
|
|
return http.Fail(c, 409, "a payment on this invoice is in progress", nil)
|
|
}
|
|
if err != nil {
|
|
return http.Fail(c, 500, "failed to lock the invoice", err)
|
|
}
|
|
defer release()
|
|
inv := billinginvoice.New(db)
|
|
if err := inv.GetById(id); err != nil {
|
|
return http.Fail(c, 404, "invoice not found", err)
|
|
}
|
|
// Tax is set while the invoice is a draft, like every other amount on it. An
|
|
// issued invoice owes a fixed AmountDue that payments are measured against;
|
|
// recomputing it would count credits a payment already applied a second time.
|
|
if inv.Status != billinginvoice.Draft {
|
|
return http.Fail(c, 400, "tax can only be calculated on a draft invoice", nil)
|
|
}
|
|
|
|
// Build customer address from query parameters
|
|
addr := &types.Address{
|
|
Country: c.Query("country"),
|
|
State: c.Query("state"),
|
|
PostalCode: c.Query("postalCode"),
|
|
City: c.Query("city"),
|
|
}
|
|
|
|
if addr.Country == "" {
|
|
return http.Fail(c, 400, "country query parameter is required for tax calculation", nil)
|
|
}
|
|
|
|
taxLines, totalTax, err := engine.CalculateInvoiceTax(db, inv, addr)
|
|
if err != nil {
|
|
log.Error("Failed to calculate tax: %v", err, c)
|
|
return http.Fail(c, 500, "failed to calculate tax", err)
|
|
}
|
|
|
|
inv.Tax = totalTax
|
|
if inv.Subtotal > 0 {
|
|
inv.TaxPercent = float64(totalTax) / float64(inv.Subtotal) * 100
|
|
}
|
|
|
|
// Recalculate AmountDue
|
|
inv.AmountDue = inv.Subtotal + inv.Tax - inv.Discount - inv.CreditApplied
|
|
if inv.AmountDue < 0 {
|
|
inv.AmountDue = 0
|
|
}
|
|
|
|
if err := inv.Update(); err != nil {
|
|
log.Error("Failed to update invoice with tax: %v", err, c)
|
|
return http.Fail(c, 500, "failed to update invoice", err)
|
|
}
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"invoice": invoiceResponse(inv),
|
|
"taxLines": taxLines,
|
|
"totalTax": totalTax,
|
|
})
|
|
}
|