mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Square keeps an event's created_at across every retry, and the receiver refused anything older than five minutes, so every retry of an event first sent while cloud was down (Recreate, ten rollouts a day) was refused until Square gave up. The verifier now lives in payment/providers/square and needs only the key and the URL; thirdparty/square is the API client and no longer registers itself or verifies anything, so every registry holds one Square type. Replay is stopped by the event id instead: each verified event is recorded once for the whole deployment, keyed on the verifying processor and the event id with an insert-if-absent swap, so a repeat is a duplicate under any path or org. The path names the one processor that verifies, and only one that holds its webhook secret may: an unknown name, or a verifier that needs no secret, is refused rather than tried. The books an event lands in are the verifying account's, else the deployment's brand — never a header or session, which on a replay are the replayer's. An effect that did not land (a store or the ledger did not answer) leaves the delivery open with a 503, so the provider redelivers; it used to be acknowledged and never retried. A settlement reads the payment index before the org's books, because an in-session charge's receipt is in the payer's org. POST /v1/commerce/webhooks/:provider is declared once, in setupRoutes, so standalone and embedded commerce serve the same address; the /v1/billing/webhooks spelling is gone. thirdparty/kms read /v1/kms/orgs/<org>/secrets/..., which cloud no longer serves, and was off in production anyway, so per-org payment credentials never loaded. It is deleted. secrets.Org reads orgs/<org>/<name> through the host's reader, the caller re-pointed at that org with zip.ActingAs so the KMS tenancy check admits it, and payment.Hydrate loads an org's credentials where the org receives the money: storefront checkout, storefront subscriptions and wire, and the brand's bank for the billing wire rail. Hanzo's own billing no longer reads the paying org's credentials; it charges the deployment's account, as it did. The standalone binary reads through hanzoai/kms/sdk/go.
412 lines
14 KiB
Go
412 lines
14 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/models/organization"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/util/json/http"
|
|
)
|
|
|
|
type createInvoiceRequest struct {
|
|
UserId string `json:"userId"`
|
|
CustomerEmail string `json:"customerEmail"`
|
|
SubscriptionId string `json:"subscriptionId"`
|
|
Currency string `json:"currency"`
|
|
LineItems []billinginvoice.LineItem `json:"lineItems"`
|
|
Metadata map[string]interface{} `json:"metadata"`
|
|
}
|
|
|
|
// CreateInvoice creates a new draft billing invoice.
|
|
//
|
|
// POST /v1/billing/invoices
|
|
func CreateInvoice(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
|
|
var req createInvoiceRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return http.Fail(c, 400, "invalid request body", err)
|
|
}
|
|
|
|
in := InvoiceIn{
|
|
UserID: req.UserId,
|
|
CustomerEmail: req.CustomerEmail,
|
|
Currency: req.Currency,
|
|
}
|
|
for _, li := range req.LineItems {
|
|
in.Lines = append(in.Lines, InvoiceLine{
|
|
Description: li.Description,
|
|
Amount: li.Amount,
|
|
Quantity: li.Quantity,
|
|
UnitPrice: li.UnitPrice,
|
|
})
|
|
}
|
|
inv, f := raiseInvoice(c.Context(), org, in)
|
|
if f != nil {
|
|
return http.Fail(c, f.Status, f.Message, f.Err)
|
|
}
|
|
// SubscriptionId and Metadata are carried by this endpoint only; the typed op has
|
|
// no field for either, so they are set after the shared core has built the row.
|
|
if req.SubscriptionId != "" || req.Metadata != nil {
|
|
inv.SubscriptionId = req.SubscriptionId
|
|
if req.Metadata != nil {
|
|
inv.Metadata = req.Metadata
|
|
}
|
|
if err := inv.Update(); err != nil {
|
|
log.Error("Failed to attach subscription/metadata to invoice: %v", err, c)
|
|
}
|
|
}
|
|
return c.JSON(201, invoiceResponse(inv))
|
|
}
|
|
|
|
// ListInvoices is the org's billing invoices, filtered — the QUERY, with no HTTP
|
|
// in it.
|
|
//
|
|
// It takes values rather than a request so a caller that is not a request can
|
|
// ask: the same list is read over the internal plane by a peer process that
|
|
// keeps no invoice store, and re-deriving this query there would be a second
|
|
// implementation of one question. Two invoice filters is how a billing page and
|
|
// a dunning run come to disagree about what is owed.
|
|
//
|
|
// Empty userID, status or subscriptionID means "do not filter on it", which is
|
|
// what an absent query parameter has always meant here.
|
|
//
|
|
// The list is paged: newest first, bounded by (limit, offset). A page that
|
|
// states no limit gets [PageParams] default, and no page — from this query or
|
|
// any other caller of it — ever returns more than the max, so one answer
|
|
// cannot grow with the org's whole billing history.
|
|
//
|
|
// The rows come back as the same view the HTTP endpoints render, so a peer reads the
|
|
// fields it already knows. The ENVELOPE around them is each endpoint's own; putting
|
|
// it here would make every future caller inherit one endpoint's shape.
|
|
func ListInvoices(ctx context.Context, org *organization.Organization, userID, status, subscriptionID string, limit, offset int) ([]Invoice, error) {
|
|
if org == nil {
|
|
return nil, fmt.Errorf("invoices: %w", errNoOrg)
|
|
}
|
|
limit, offset = PageParams(limit, offset)
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
// Under no ancestor: an invoice saved again after it was loaded by id — every
|
|
// paid one — is stored without the synckey parent it was created under.
|
|
q := billinginvoice.Query(db)
|
|
if userID != "" {
|
|
q = q.Filter("UserId=", userID)
|
|
}
|
|
if status != "" {
|
|
q = q.Filter("Status=", status)
|
|
}
|
|
if subscriptionID != "" {
|
|
q = q.Filter("SubscriptionId=", subscriptionID)
|
|
}
|
|
q = q.Order("-CreatedAt").Limit(limit).Offset(offset)
|
|
|
|
invoices := make([]*billinginvoice.BillingInvoice, 0)
|
|
if _, err := q.GetAll(&invoices); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items := make([]Invoice, 0, len(invoices))
|
|
for _, inv := range invoices {
|
|
items = append(items, invoiceResponse(inv))
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// Invoice page shape: one answer holds at most InvoicePageMax rows, and a
|
|
// missing limit is the default, not "everything".
|
|
const (
|
|
InvoicePageDefault = 50
|
|
InvoicePageMax = 200
|
|
)
|
|
|
|
// PageParams normalizes a page request the way this listing answers it:
|
|
// an absent limit is the default, a limit past the max is the max, and a
|
|
// negative offset is zero. One rule, so the HTTP surface and every peer
|
|
// reader of ListInvoices cannot agree on two.
|
|
func PageParams(limit, offset int) (int, int) {
|
|
switch {
|
|
case limit <= 0:
|
|
limit = InvoicePageDefault
|
|
case limit > InvoicePageMax:
|
|
limit = InvoicePageMax
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
return limit, offset
|
|
}
|
|
|
|
// PageCursor encodes the page a reader resumes from: the next row's offset,
|
|
// base64 (URL-safe, no padding) so it round-trips through a query string
|
|
// untouched. The cursor is opaque to the reader — a position, not a promise
|
|
// about how the rows are ordered.
|
|
func PageCursor(offset int) string {
|
|
return base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset)))
|
|
}
|
|
|
|
// PageOffset decodes a [PageCursor]; an absent cursor is offset zero, and a
|
|
// malformed one is a refused page, not a guess.
|
|
func PageOffset(cursor string) (int, error) {
|
|
if cursor == "" {
|
|
return 0, nil
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(cursor)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invoices: cursor does not decode: %w", err)
|
|
}
|
|
offset, err := strconv.Atoi(string(raw))
|
|
if err != nil || offset < 0 {
|
|
return 0, fmt.Errorf("invoices: cursor names no row position")
|
|
}
|
|
return offset, nil
|
|
}
|
|
|
|
// ListBillingInvoices lists billing invoices, filtered by userId/status/
|
|
// subscriptionId and paged: ?limit= (default 50, max 200) and ?cursor=
|
|
// (the page a previous answer returned — absent starts at the newest row).
|
|
//
|
|
// GET /v1/billing/invoices?userId=...&status=...&limit=...&cursor=...
|
|
func ListBillingInvoices(c *zip.Ctx) error {
|
|
// #146 class: never panic on a missing org. On the co-resident cloud embed path
|
|
// this read can run with no "organization" local (IAMTokenRequired no-ops with no
|
|
// gateway X-Org-Id) — GetOrganization would panic → 502. No org ⇒ honest empty.
|
|
org, ok := middleware.GetOrganizationOK(c)
|
|
if !ok || org == nil {
|
|
return c.JSON(200, map[string]any{"invoices": []map[string]any{}, "count": 0})
|
|
}
|
|
|
|
limit := 0
|
|
if s := c.Query("limit"); s != "" {
|
|
limit, _ = strconv.Atoi(s) // a malformed limit is the default, not a 500
|
|
}
|
|
offset, err := PageOffset(c.Query("cursor"))
|
|
if err != nil {
|
|
return http.Fail(c, 400, "failed to list invoices", err)
|
|
}
|
|
|
|
items, err := ListInvoices(c.Context(), org,
|
|
strings.TrimSpace(c.Query("userId")),
|
|
strings.TrimSpace(c.Query("status")),
|
|
strings.TrimSpace(c.Query("subscriptionId")),
|
|
limit, offset)
|
|
if err != nil {
|
|
log.Error("Failed to list invoices: %v", err, c)
|
|
return http.Fail(c, 500, "failed to list invoices", err)
|
|
}
|
|
|
|
resp := map[string]any{"invoices": items, "count": len(items)}
|
|
// A full page is the only shape that may hold a next page.
|
|
if eff, _ := PageParams(limit, offset); len(items) == eff {
|
|
resp["cursor"] = PageCursor(offset + eff)
|
|
}
|
|
return c.JSON(200, resp)
|
|
}
|
|
|
|
// GetInvoice returns a single billing invoice by ID.
|
|
//
|
|
// GET /v1/billing/invoices/:id
|
|
func GetInvoice(c *zip.Ctx) error {
|
|
inv, _, f := loadInvoice(c.Context(), middleware.GetOrganization(c), c.Param("id"))
|
|
if f != nil {
|
|
return http.Fail(c, f.Status, f.Message, f.Err)
|
|
}
|
|
return c.JSON(200, invoiceResponse(inv))
|
|
}
|
|
|
|
// FinalizeInvoice transitions an invoice from draft to open.
|
|
//
|
|
// POST /v1/billing/invoices/:id/finalize
|
|
func FinalizeInvoice(c *zip.Ctx) error {
|
|
inv, f := issueInvoice(c.Context(), middleware.GetOrganization(c), c.Param("id"), eventsOf(c))
|
|
if f != nil {
|
|
return http.Fail(c, f.Status, f.Message, f.Err)
|
|
}
|
|
return c.JSON(200, invoiceResponse(inv))
|
|
}
|
|
|
|
// PayInvoice attempts to collect payment on an open invoice.
|
|
//
|
|
// POST /v1/billing/invoices/:id/pay
|
|
func PayInvoice(c *zip.Ctx) error {
|
|
oc, f := collectInvoice(c.Context(), middleware.GetOrganization(c), c.Param("id"), eventsOf(c))
|
|
if f != nil {
|
|
return http.Fail(c, f.Status, f.Message, f.Err)
|
|
}
|
|
if oc.Replayed != nil {
|
|
return c.JSON(200, map[string]any{
|
|
"invoice": invoiceResponse(oc.Inv),
|
|
"collection": oc.Replayed,
|
|
})
|
|
}
|
|
return c.JSON(200, map[string]any{
|
|
"invoice": invoiceResponse(oc.Inv),
|
|
"collection": oc.Result,
|
|
})
|
|
}
|
|
|
|
// VoidInvoice voids a draft or open invoice.
|
|
//
|
|
// POST /v1/billing/invoices/:id/void
|
|
func VoidInvoice(c *zip.Ctx) error {
|
|
inv, f := voidInvoice(c.Context(), middleware.GetOrganization(c), c.Param("id"), eventsOf(c))
|
|
if f != nil {
|
|
return http.Fail(c, f.Status, f.Message, f.Err)
|
|
}
|
|
return c.JSON(200, invoiceResponse(inv))
|
|
}
|
|
|
|
// UpcomingInvoice generates a preview of the next invoice for a subscription.
|
|
//
|
|
// GET /v1/billing/invoices/upcoming?userId=...&subscriptionId=...
|
|
func UpcomingInvoice(c *zip.Ctx) error {
|
|
org := middleware.GetOrganization(c)
|
|
db := datastore.New(org.Namespaced(c.Context()))
|
|
|
|
userId := strings.TrimSpace(c.Query("userId"))
|
|
if userId == "" {
|
|
return http.Fail(c, 400, "userId query parameter is required", nil)
|
|
}
|
|
|
|
// Aggregate current usage for this user
|
|
lineItems, subtotal, err := engine.AggregateUsage(db, userId, time.Time{}, time.Time{})
|
|
if err != nil {
|
|
log.Error("Failed to aggregate usage: %v", err, c)
|
|
return http.Fail(c, 500, "failed to aggregate usage", err)
|
|
}
|
|
|
|
// Apply credit burn-down for preview
|
|
creditApplied := int64(0)
|
|
amountDue := subtotal
|
|
|
|
if subtotal > 0 {
|
|
remaining, err := BurnCreditsPreview(db, userId, subtotal)
|
|
if err == nil {
|
|
creditApplied = subtotal - remaining
|
|
amountDue = remaining
|
|
}
|
|
}
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"userId": userId,
|
|
"lineItems": lineItems,
|
|
"subtotal": subtotal,
|
|
"creditApplied": creditApplied,
|
|
"amountDue": amountDue,
|
|
"currency": "usd",
|
|
})
|
|
}
|
|
|
|
// BurnCreditsPreview calculates credit burn without actually deducting.
|
|
// Returns the remaining amount after credits would be applied.
|
|
func BurnCreditsPreview(db *datastore.Datastore, userId string, amount int64) (int64, error) {
|
|
grants, err := getActiveGrants(db, userId)
|
|
if err != nil {
|
|
return amount, err
|
|
}
|
|
|
|
remaining := amount
|
|
for _, g := range grants {
|
|
if remaining <= 0 {
|
|
break
|
|
}
|
|
deduct := g.RemainingCents
|
|
if deduct > remaining {
|
|
deduct = remaining
|
|
}
|
|
remaining -= deduct
|
|
}
|
|
|
|
return remaining, nil
|
|
}
|
|
|
|
// Invoice is a billing invoice as every invoice endpoint has answered with it —
|
|
// InvoiceView's facts plus the ones only the endpoints carry (the period, the tax
|
|
// and discount lines, the dunning attempt count).
|
|
//
|
|
// The three lifecycle timestamps are pointers: each key has always been emitted
|
|
// only once its moment has happened, and a due date of the zero time is a
|
|
// different claim from an invoice that is not yet due.
|
|
type Invoice struct {
|
|
ID string `json:"id"`
|
|
UserID string `json:"userId"`
|
|
CustomerEmail string `json:"customerEmail"`
|
|
SubscriptionID string `json:"subscriptionId"`
|
|
PeriodStart time.Time `json:"periodStart"`
|
|
PeriodEnd time.Time `json:"periodEnd"`
|
|
Subtotal int64 `json:"subtotal"`
|
|
Tax int64 `json:"tax"`
|
|
Discount int64 `json:"discount"`
|
|
CreditApplied int64 `json:"creditApplied"`
|
|
AmountDue int64 `json:"amountDue"`
|
|
AmountPaid int64 `json:"amountPaid"`
|
|
Currency currency.Type `json:"currency"`
|
|
Status billinginvoice.Status `json:"status"`
|
|
PaymentMethod string `json:"paymentMethod"`
|
|
PaymentRef string `json:"paymentRef"`
|
|
Number int `json:"number"`
|
|
NumberStr string `json:"numberStr"`
|
|
AttemptCount int `json:"attemptCount"`
|
|
LineItems []billinginvoice.LineItem `json:"lineItems"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
DueDate *time.Time `json:"dueDate,omitempty"`
|
|
PaidAt *time.Time `json:"paidAt,omitempty"`
|
|
VoidedAt *time.Time `json:"voidedAt,omitempty"`
|
|
}
|
|
|
|
// invoiceResponse projects the model onto that view. It stays the ONE projection
|
|
// the endpoints share, now typed, so the list a peer reads and the body a browser
|
|
// reads cannot drift into describing one invoice two ways.
|
|
func invoiceResponse(inv *billinginvoice.BillingInvoice) Invoice {
|
|
resp := Invoice{
|
|
ID: inv.Id(),
|
|
UserID: inv.UserId,
|
|
CustomerEmail: inv.CustomerEmail,
|
|
SubscriptionID: inv.SubscriptionId,
|
|
PeriodStart: inv.PeriodStart,
|
|
PeriodEnd: inv.PeriodEnd,
|
|
Subtotal: inv.Subtotal,
|
|
Tax: inv.Tax,
|
|
Discount: inv.Discount,
|
|
CreditApplied: inv.CreditApplied,
|
|
AmountDue: inv.AmountDue,
|
|
AmountPaid: inv.AmountPaid,
|
|
Currency: inv.Currency,
|
|
Status: inv.Status,
|
|
PaymentMethod: inv.PaymentMethod,
|
|
PaymentRef: inv.PaymentRef,
|
|
Number: inv.Number,
|
|
NumberStr: inv.NumberStr,
|
|
AttemptCount: inv.AttemptCount,
|
|
LineItems: inv.LineItems,
|
|
CreatedAt: inv.CreatedAt,
|
|
UpdatedAt: inv.UpdatedAt,
|
|
}
|
|
|
|
if !inv.DueDate.IsZero() {
|
|
due := inv.DueDate
|
|
resp.DueDate = &due
|
|
}
|
|
if !inv.PaidAt.IsZero() {
|
|
paid := inv.PaidAt
|
|
resp.PaidAt = &paid
|
|
}
|
|
if !inv.VoidedAt.IsZero() {
|
|
voided := inv.VoidedAt
|
|
resp.VoidedAt = &voided
|
|
}
|
|
|
|
return resp
|
|
}
|