mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
- 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]>
91 lines
3.6 KiB
Go
91 lines
3.6 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/models/billinginvoice"
|
|
"github.com/hanzoai/commerce/models/creditgrant"
|
|
"github.com/hanzoai/commerce/types"
|
|
)
|
|
|
|
// ReturnPaid gives back what an invoice collected toward a period that will not
|
|
// be served, before it is voided or written off as uncollectible: the credit it
|
|
// applied (CreditApplied) as a credit grant, and the rest to the subject's
|
|
// balance on the one ledger (p.Return). Both are keyed by the invoice, so
|
|
// returning twice returns once, and the invoice records what was returned in
|
|
// Metadata["returnedCents"]. The caller persists the invoice.
|
|
//
|
|
// The rest goes to the balance whatever collected it: a card leg that settled
|
|
// before a prepaid leg failed is given back as prepaid money the customer can
|
|
// spend, not refunded to the card.
|
|
func ReturnPaid(ctx context.Context, db *datastore.Datastore, inv *billinginvoice.BillingInvoice, p Prepaid, now time.Time) error {
|
|
if inv.AmountPaid <= 0 {
|
|
return nil
|
|
}
|
|
if _, done := inv.Metadata["returnedCents"]; done {
|
|
return nil
|
|
}
|
|
cur := invoiceCurrency(inv)
|
|
credit := min(inv.CreditApplied, inv.AmountPaid)
|
|
if credit > 0 {
|
|
g := creditgrant.New(db)
|
|
if err := g.SetKey(returnKey(db, inv)); err != nil {
|
|
return err
|
|
}
|
|
switch err := g.Get(g.Key()); {
|
|
case err == nil:
|
|
case errors.Is(err, datastore.ErrNoSuchEntity):
|
|
g.UserId = inv.UserId
|
|
g.Name = "Returned from invoice " + inv.NumberStr
|
|
g.AmountCents, g.RemainingCents = credit, credit
|
|
g.Currency = cur
|
|
g.EffectiveAt = now
|
|
g.Tags = "invoice-return"
|
|
g.Metadata = types.Map{"invoiceId": inv.Id()}
|
|
if err := g.Create(); err != nil {
|
|
return fmt.Errorf("return %d cents of credit from invoice %s: %w", credit, inv.Id(), err)
|
|
}
|
|
default:
|
|
return fmt.Errorf("read the credit returned from invoice %s: %w", inv.Id(), err)
|
|
}
|
|
}
|
|
if rest := inv.AmountPaid - credit; rest > 0 {
|
|
if p == nil {
|
|
return fmt.Errorf("invoice %s collected %d cents and there is no ledger to return them to", inv.Id(), rest)
|
|
}
|
|
ref, err := p.Return(ctx, inv.UserId, cur, rest, "invoice-return:"+inv.Id())
|
|
if err != nil {
|
|
return fmt.Errorf("return %d cents to the balance from invoice %s: %w", rest, inv.Id(), err)
|
|
}
|
|
// Money a card may have paid comes back as balance, not to the card: each
|
|
// such return is an item for operations to review as a refund.
|
|
data := types.Map{"invoiceId": inv.Id(), "subscriptionId": inv.SubscriptionId, "balanceCents": rest,
|
|
"creditCents": credit, "currency": string(cur), "ledgerRef": ref,
|
|
"paymentMethod": inv.PaymentMethod, "paymentRef": inv.PaymentRef}
|
|
if _, err := EmitBillingEventOnce(db, "invoice.returned:"+inv.Id(), "invoice.returned", "invoice", inv.Id(), inv.UserId, data); err != nil {
|
|
return fmt.Errorf("record the return from invoice %s: %w", inv.Id(), err)
|
|
}
|
|
log.Warn("billing: REFUND REVIEW invoice %s returned %d cents to %s's balance (payment %s %s)",
|
|
inv.Id(), rest, inv.UserId, inv.PaymentMethod, inv.PaymentRef)
|
|
}
|
|
if inv.Metadata == nil {
|
|
inv.Metadata = types.Map{}
|
|
}
|
|
inv.Metadata["returnedCents"] = inv.AmountPaid
|
|
return nil
|
|
}
|
|
|
|
// returnKey is the deterministic storage key of the credit grant an invoice's
|
|
// return writes.
|
|
func returnKey(db *datastore.Datastore, inv *billinginvoice.BillingInvoice) datastore.Key {
|
|
sum := sha256.Sum256([]byte("invoice-return\x00credit-grant\x00" + inv.Id()))
|
|
return db.NewKey("credit-grant", "rtrn_"+hex.EncodeToString(sum[:16]), 0, db.NewKey("synckey", "", 1, nil))
|
|
}
|