Files
zooqueenandHanzo Dev 06ee3180b4 An overdue subscription confers nothing and is replaced when its customer subscribes again, renewals of one row never charge twice, and a live cycle waits for the realignment
- An active subscription past its paid period and the 72-hour grace, or a
  past-due one past the grace and the whole retry schedule, is lapsed: it
  confers no tier or plan, a new subscription on any purchase path replaces
  it at its paid period's end without billing the time since, and a late
  outside payment extends its lapsed external plan.
- A past-due or unpaid subscription holds its subject: no second plan is sold
  beside it.
- The renew endpoint reads the row under the org's cycle lock. Carrying an
  overdue row to now is guarded once per missed period end and made only when
  the row still ends where it was read.
- The renewal step locks per subscription, taken once, so no step waits on a
  lock it holds.
- A customer renewing an overdue row whose declined renewal's period is over
  is renewed from now instead of ended.
- Seat and bundle rows follow their paying subscription's period, so members
  are served and granted each month the payment covers.
- The cycle repeats, under its key, a payment attempt of unknown outcome that
  a canceled subscription's invoice still carries, and an immediate cancel
  that leaves one logs it.
- A card renewal with no card on file is a decline in a dry run as live, and
  a dry run logs no alerts.
- A live cycle is refused (409) while a subscription sits a period ahead of
  its paid invoice; a dry run reports each as realign_pending.
- An operator's void is recorded on the invoice in the write that voids it,
  then in the billing event ledger once per invoice; a retry writes a missing
  event.
- Every return of an invoice's collection to the balance is an
  invoice.returned billing event for refund review.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 11:58:32 -07:00

84 lines
3.0 KiB
Go

package engine
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/models/billingevent"
. "github.com/hanzoai/commerce/types"
)
// Emit records a billing event, fully detached from the caller's request
// lifecycle (fire-and-forget). ctx MUST already carry the org namespace
// (org.Namespaced). Errors are logged, never returned — the event ledger must
// NEVER touch the money path.
//
// DELIVERY LIVES ELSEWHERE. Webhook delivery to subscriber URLs is the
// platform-global layer (hanzoai/cloud clients/webhooks: /v1/webhooks registry
// + the bus-driven dispatcher). Commerce publishes lifecycle events to the bus
// (events/publisher.go); this ledger is the queryable audit record.
func Emit(ctx context.Context, eventType, objectType, objectId, customerId string, data Map) {
go func() {
db := datastore.New(ctx)
if _, err := EmitBillingEvent(db, eventType, objectType, objectId, customerId, data, nil); err != nil {
log.Error("billing webhook emit %s (%s %s): %v", eventType, objectType, objectId, err)
}
}()
}
// EmitBillingEvent creates an append-only billing event record. It runs
// synchronously; callers that must stay off the money path invoke it via Emit
// (goroutine + detached context).
func EmitBillingEvent(db *datastore.Datastore, eventType, objectType, objectId, customerId string, data, previousData Map) (*billingevent.BillingEvent, error) {
evt := billingevent.New(db)
evt.Type = eventType
evt.ObjectType = objectType
evt.ObjectId = objectId
evt.CustomerId = customerId
evt.Data = data
evt.PreviousData = previousData
evt.Pending = true
if err := evt.Create(); err != nil {
return nil, fmt.Errorf("failed to create billing event: %w", err)
}
return evt, nil
}
// EmitBillingEventOnce records the billing event that key names, once: it is
// stored under a storage key derived from key, and a later call with the same key
// answers the event already there. An act that is retried after its event was
// written, or whose event is written again after the act, records one event.
func EmitBillingEventOnce(db *datastore.Datastore, key, eventType, objectType, objectId, customerId string, data Map) (*billingevent.BillingEvent, error) {
sum := sha256.Sum256([]byte("billing-event\x00" + key))
storageKey := db.NewKey("billing-event", "bevt_"+hex.EncodeToString(sum[:16]), 0, db.NewKey("synckey", "", 1, nil))
evt := billingevent.New(db)
switch err := evt.Get(storageKey); {
case err == nil:
return evt, nil
case !errors.Is(err, datastore.ErrNoSuchEntity):
return nil, fmt.Errorf("read billing event %s: %w", key, err)
}
evt = billingevent.New(db)
if err := evt.SetKey(storageKey); err != nil {
return nil, err
}
evt.Type = eventType
evt.ObjectType = objectType
evt.ObjectId = objectId
evt.CustomerId = customerId
evt.Data = data
evt.Pending = true
if err := evt.Create(); err != nil {
return nil, fmt.Errorf("failed to create billing event: %w", err)
}
return evt, nil
}