Files
zeekay fa4fc53b14 Pay a recorded plan from the prepaid balance
RecordSubscription takes processor "balance": it draws the period from
the subject's prepaid money now, raises and settles that period's
invoice, and opens a regular subscription the engine renews from the
same money. The period must be the one running now and one of the
plan's; a subject holding a plan, or one gone past due, is refused. A
balance that cannot cover the period is refused with nothing written.
The ledger entry and the invoice are the reference.

Prepaid money is read and spent one way. The creditledger seam gains
Debit, all or nothing at the address Credit and Balance already name,
so with a host ledger injected the draw lands where a grant credits and
the AI gate reads, and the tier reads its balance, beside the credit
grants, from there too. Standalone, the draw is an "iam-user"
withdrawal, the kind every deposit writes. The self-serve balance
purchase, the record and every renewal draw through it, credit grants
first, exactly once per ref. The split is recorded under the ref
before the ledger is asked, so a debit whose answer was lost is posted
again as it was and the ledger answers with the posting it holds.

The collector reads the allocation first and charges the card before
any prepaid money moves, so an invoice that cannot be paid in full
takes nothing from credits or balance. What an attempt does collect
accumulates on AmountPaid, and the draw is keyed on the subscription's
period, so two invoices raised for one period draw once between them.

Periods are billed in advance. A subscription holds the period it paid
for; when that period ends the next one is invoiced, fee in advance and
metered usage in arrears, and the row moves onto it only once it is
paid. An unpaid one leaves the row past due on the period it owes;
once that period has gone by unserved it is owed no longer, its
invoice is void, and the row owes the period running now. A row that
was served nothing until it paid, past due or late, is served a whole
period from the payment. An invoice is voided only under the guard its
payment takes, so a payment landing at once is never voided over. A
row whose renewal was missed for whole periods is billed the period
running now, never every period since. A row canceled at the end of
its period ends with the last period it paid for, and an ended row's
unpaid invoices are void.

Invoices are read by their filters rather than under the synckey
ancestor an invoice loses once it is saved again, so a paid one stays
listed and found by its renewal, and a new one is numbered one past
the highest issued. The billing cycle reads
subscriptions the same way, renews only rows of the org's own mode,
and RenewDue is the sweep as a value core a schedule can call.

Claude-Session: https://claude.ai/code/session_016hTDbBTaq6r3xTQ5RAVVtF
2026-09-23 22:09:15 -07:00

2662 lines
79 KiB
Go

package engine
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/hanzoai/commerce/models/billinginvoice"
"github.com/hanzoai/commerce/models/credit"
"github.com/hanzoai/commerce/models/plan"
"github.com/hanzoai/commerce/models/subscription"
"github.com/hanzoai/commerce/models/subscriptionitem"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/types"
)
// ---------------------------------------------------------------------------
// intents.go — CreatePaymentIntent validation
// ---------------------------------------------------------------------------
func TestCreatePaymentIntent_ZeroAmount(t *testing.T) {
_, err := CreatePaymentIntent(nil, CreatePaymentIntentParams{
CustomerId: "cus_123",
Amount: 0,
Currency: "usd",
})
if err == nil {
t.Fatal("expected error for zero amount")
}
if !strings.Contains(err.Error(), "amount must be positive") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestCreatePaymentIntent_NegativeAmount(t *testing.T) {
_, err := CreatePaymentIntent(nil, CreatePaymentIntentParams{
CustomerId: "cus_123",
Amount: -500,
Currency: "usd",
})
if err == nil {
t.Fatal("expected error for negative amount")
}
if !strings.Contains(err.Error(), "amount must be positive") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestCreatePaymentIntent_MissingCustomerId(t *testing.T) {
_, err := CreatePaymentIntent(nil, CreatePaymentIntentParams{
Amount: 1000,
Currency: "usd",
})
if err == nil {
t.Fatal("expected error for missing customerId")
}
if !strings.Contains(err.Error(), "customerId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestCreatePaymentIntent_ValidParams_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: paymentintent.New(db) needs live db")
}
// ---------------------------------------------------------------------------
// intents.go — CreateSetupIntent validation
// ---------------------------------------------------------------------------
func TestCreateSetupIntent_MissingCustomerId(t *testing.T) {
_, err := CreateSetupIntent(nil, CreateSetupIntentParams{})
if err == nil {
t.Fatal("expected error for missing customerId")
}
if !strings.Contains(err.Error(), "customerId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestCreateSetupIntent_ValidParams_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: setupintent.New(db) needs live db")
}
// ---------------------------------------------------------------------------
// refunds.go — CreateRefund validation
// ---------------------------------------------------------------------------
func TestCreateRefund_MissingBothIds(t *testing.T) {
_, err := CreateRefund(nil, nil, CreateRefundParams{
Reason: "requested_by_customer",
}, nil)
if err == nil {
t.Fatal("expected error when both paymentIntentId and invoiceId are missing")
}
if !strings.Contains(err.Error(), "either paymentIntentId or invoiceId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestCreateRefund_WithPaymentIntentId_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: paymentintent.New(db).GetById needs live db")
}
func TestCreateRefund_WithInvoiceId_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: billinginvoice.New(db).GetById needs live db")
}
// ---------------------------------------------------------------------------
// refunds.go — CreateCreditNote validation
// ---------------------------------------------------------------------------
func TestCreateCreditNote_MissingInvoiceId(t *testing.T) {
_, err := CreateCreditNote(nil, CreateCreditNoteParams{
CustomerId: "cus_123",
Amount: 500,
})
if err == nil {
t.Fatal("expected error for missing invoiceId")
}
if !strings.Contains(err.Error(), "invoiceId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestCreateCreditNote_ValidParams_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: billinginvoice.New(db).GetById needs live db")
}
// ---------------------------------------------------------------------------
// metering.go — IngestUsageEvent validation
// ---------------------------------------------------------------------------
func TestIngestUsageEvent_MissingMeterId(t *testing.T) {
_, _, err := IngestUsageEvent(nil, "", "user_1", 10, "", time.Now(), nil)
if err == nil {
t.Fatal("expected error for missing meterId")
}
if !strings.Contains(err.Error(), "meterId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestIngestUsageEvent_MissingUserId(t *testing.T) {
_, _, err := IngestUsageEvent(nil, "meter_1", "", 10, "", time.Now(), nil)
if err == nil {
t.Fatal("expected error for missing userId")
}
if !strings.Contains(err.Error(), "userId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestIngestUsageEvent_ValidParams_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: meter.NewEvent(db) needs live db")
}
// ---------------------------------------------------------------------------
// tax.go — TaxLine struct and CalculateInvoiceTax nil-address path
// ---------------------------------------------------------------------------
func TestCalculateInvoiceTax_NilAddress(t *testing.T) {
lines, total, err := CalculateInvoiceTax(nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if total != 0 {
t.Fatalf("expected 0 tax for nil address, got %d", total)
}
if len(lines) != 0 {
t.Fatalf("expected no tax lines for nil address, got %d", len(lines))
}
}
func TestTaxLine_Struct(t *testing.T) {
tl := TaxLine{
TaxRateId: "txr_001",
Description: "CA State Tax",
Amount: 875,
Rate: 0.0875,
Inclusive: false,
Jurisdiction: "US-CA",
}
if tl.TaxRateId != "txr_001" {
t.Fatalf("TaxRateId mismatch: %s", tl.TaxRateId)
}
if tl.Amount != 875 {
t.Fatalf("Amount mismatch: %d", tl.Amount)
}
if tl.Rate != 0.0875 {
t.Fatalf("Rate mismatch: %f", tl.Rate)
}
if tl.Jurisdiction != "US-CA" {
t.Fatalf("Jurisdiction mismatch: %s", tl.Jurisdiction)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — StartSubscription (pure in-memory)
// ---------------------------------------------------------------------------
func makePlan(id, name string, price int64, interval types.Interval, intervalCount, trialDays int) *plan.Plan {
p := &plan.Plan{
Name: name,
Price: currency.Cents(price),
Currency: "usd",
Interval: interval,
IntervalCount: intervalCount,
TrialPeriodDays: trialDays,
}
// Pre-set Id_ so p.Id() returns without calling into datastore
p.Id_ = id
return p
}
func TestStartSubscription_NoTrial(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_pro", "Pro", 2000, types.Monthly, 1, 0)
StartSubscription(sub, p)
if sub.Status != subscription.Active {
t.Fatalf("expected Active, got %s", sub.Status)
}
if sub.PlanId != p.Id() {
t.Fatalf("planId mismatch: got %s, want %s", sub.PlanId, p.Id())
}
if sub.Start.IsZero() {
t.Fatal("Start should be set")
}
if sub.PeriodStart.IsZero() {
t.Fatal("PeriodStart should be set")
}
if sub.PeriodEnd.IsZero() {
t.Fatal("PeriodEnd should be set")
}
if !sub.TrialStart.IsZero() {
t.Fatal("TrialStart should be zero for no-trial plan")
}
// Period should be 1 month later
expected := sub.PeriodStart.AddDate(0, 1, 0)
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("PeriodEnd mismatch: got %v, want %v", sub.PeriodEnd, expected)
}
}
func TestStartSubscription_WithTrial(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_ent", "Enterprise", 10000, types.Monthly, 1, 14)
StartSubscription(sub, p)
if sub.Status != subscription.Trialing {
t.Fatalf("expected Trialing, got %s", sub.Status)
}
if sub.TrialStart.IsZero() {
t.Fatal("TrialStart should be set")
}
if sub.TrialEnd.IsZero() {
t.Fatal("TrialEnd should be set")
}
// Trial should be 14 days (compare by calendar date, not hours, to avoid DST drift)
expectedTrialEnd := sub.TrialStart.AddDate(0, 0, 14)
if !sub.TrialEnd.Equal(expectedTrialEnd) {
t.Fatalf("trial end mismatch: got %v, want %v", sub.TrialEnd, expectedTrialEnd)
}
// Period should start at trial end
if !sub.PeriodStart.Equal(sub.TrialEnd) {
t.Fatalf("PeriodStart should equal TrialEnd")
}
}
func TestStartSubscription_YearlyInterval(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_annual", "Annual", 20000, types.Yearly, 1, 0)
StartSubscription(sub, p)
expected := sub.PeriodStart.AddDate(1, 0, 0)
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("yearly PeriodEnd mismatch: got %v, want %v", sub.PeriodEnd, expected)
}
}
func TestStartSubscription_MultiMonthInterval(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_q", "Quarterly", 5000, types.Monthly, 3, 0)
StartSubscription(sub, p)
expected := sub.PeriodStart.AddDate(0, 3, 0)
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("quarterly PeriodEnd mismatch: got %v, want %v", sub.PeriodEnd, expected)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — TransitionTrialToActive
// ---------------------------------------------------------------------------
func TestTransitionTrialToActive_Success(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Trialing}
if err := TransitionTrialToActive(sub); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.Status != subscription.Active {
t.Fatalf("expected Active, got %s", sub.Status)
}
}
func TestTransitionTrialToActive_NotTrialing(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Active}
err := TransitionTrialToActive(sub)
if err == nil {
t.Fatal("expected error when not trialing")
}
if !strings.Contains(err.Error(), "not trialing") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestTransitionTrialToActive_Canceled(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Canceled}
err := TransitionTrialToActive(sub)
if err == nil {
t.Fatal("expected error for canceled subscription")
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — CancelSubscription
// ---------------------------------------------------------------------------
func TestCancelSubscription_Immediate(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Active}
if err := CancelSubscription(sub, false); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.Status != subscription.Canceled {
t.Fatalf("expected Canceled, got %s", sub.Status)
}
if !sub.Canceled {
t.Fatal("Canceled flag should be true")
}
if sub.CanceledAt.IsZero() {
t.Fatal("CanceledAt should be set")
}
if sub.Ended.IsZero() {
t.Fatal("Ended should be set for immediate cancel")
}
if sub.EndCancel {
t.Fatal("EndCancel should be false for immediate cancel")
}
}
func TestCancelSubscription_AtPeriodEnd(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Active}
if err := CancelSubscription(sub, true); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.EndCancel != true {
t.Fatal("EndCancel should be true")
}
if sub.CanceledAt.IsZero() {
t.Fatal("CanceledAt should be set")
}
// Status should NOT change to canceled yet (deferred)
if sub.Status == subscription.Canceled {
t.Fatal("status should not be canceled yet for at-period-end cancel")
}
}
func TestCancelSubscription_AlreadyCanceled(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Canceled}
err := CancelSubscription(sub, false)
if err == nil {
t.Fatal("expected error when already canceled")
}
if !strings.Contains(err.Error(), "already canceled") {
t.Fatalf("unexpected error: %s", err)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — ReactivateSubscription
// ---------------------------------------------------------------------------
func TestReactivateSubscription_PendingCancel(t *testing.T) {
sub := &subscription.Subscription{
Status: subscription.Active,
EndCancel: true,
Canceled: true,
CanceledAt: time.Now(),
}
if err := ReactivateSubscription(sub); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.EndCancel {
t.Fatal("EndCancel should be cleared")
}
if sub.Canceled {
t.Fatal("Canceled should be cleared")
}
if !sub.CanceledAt.IsZero() {
t.Fatal("CanceledAt should be zeroed")
}
}
func TestReactivateSubscription_FullyEnded(t *testing.T) {
sub := &subscription.Subscription{
Status: subscription.Canceled,
Ended: time.Now(),
}
err := ReactivateSubscription(sub)
if err == nil {
t.Fatal("expected error for fully ended subscription")
}
if !strings.Contains(err.Error(), "fully ended") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestReactivateSubscription_CanceledButNotEnded(t *testing.T) {
sub := &subscription.Subscription{
Status: subscription.Canceled,
Canceled: true,
}
if err := ReactivateSubscription(sub); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.Status != subscription.Active {
t.Fatalf("expected Active after reactivation, got %s", sub.Status)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — ChangePlan
// ---------------------------------------------------------------------------
func TestChangePlan_NoProration(t *testing.T) {
oldPlan := makePlan("plan_basic", "Basic", 1000, types.Monthly, 1, 0)
newPlan := makePlan("plan_pro", "Pro", 2000, types.Monthly, 1, 0)
sub := &subscription.Subscription{
Plan: *oldPlan,
PlanId: "plan_basic",
}
item, err := ChangePlan(sub, newPlan, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item != nil {
t.Fatal("expected nil proration item when prorate=false")
}
if sub.PlanId != "plan_pro" {
t.Fatalf("plan should be updated to new plan")
}
}
func TestChangePlan_WithProration(t *testing.T) {
now := time.Now()
oldPlan := makePlan("plan_basic", "Basic", 3000, types.Monthly, 1, 0)
newPlan := makePlan("plan_pro", "Pro", 6000, types.Monthly, 1, 0)
sub := &subscription.Subscription{
Plan: *oldPlan,
PlanId: "plan_basic",
PeriodStart: now.AddDate(0, 0, -15),
PeriodEnd: now.AddDate(0, 0, 15),
}
item, err := ChangePlan(sub, newPlan, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item == nil {
t.Fatal("expected proration item")
}
if item.Type != billinginvoice.LineProration {
t.Fatalf("expected proration type, got %s", item.Type)
}
if item.Currency != "usd" {
t.Fatalf("expected usd currency, got %s", item.Currency)
}
// Net should be positive (upgrading from 3000 to 6000)
if item.Amount <= 0 {
t.Fatalf("expected positive proration amount for upgrade, got %d", item.Amount)
}
if !strings.Contains(item.Description, "Basic") || !strings.Contains(item.Description, "Pro") {
t.Fatalf("description should mention both plans: %s", item.Description)
}
}
func TestChangePlan_Downgrade(t *testing.T) {
now := time.Now()
oldPlan := makePlan("plan_pro", "Pro", 6000, types.Monthly, 1, 0)
newPlan := makePlan("plan_basic", "Basic", 3000, types.Monthly, 1, 0)
sub := &subscription.Subscription{
Plan: *oldPlan,
PlanId: "plan_pro",
PeriodStart: now.AddDate(0, 0, -15),
PeriodEnd: now.AddDate(0, 0, 15),
}
item, err := ChangePlan(sub, newPlan, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item == nil {
t.Fatal("expected proration item for downgrade")
}
// Net should be negative (downgrading)
if item.Amount >= 0 {
t.Fatalf("expected negative proration amount for downgrade, got %d", item.Amount)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — advancePeriod (unexported, tested via StartSubscription)
// ---------------------------------------------------------------------------
func TestAdvancePeriod_Monthly(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_m", "M", 100, types.Monthly, 1, 0)
StartSubscription(sub, p)
// Verify by calendar date arithmetic (immune to DST / short months)
expected := sub.PeriodStart.AddDate(0, 1, 0)
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("monthly period mismatch: got %v, want %v", sub.PeriodEnd, expected)
}
}
func TestAdvancePeriod_Yearly(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_y", "Y", 100, types.Yearly, 1, 0)
StartSubscription(sub, p)
expected := sub.PeriodStart.AddDate(1, 0, 0)
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("yearly period mismatch: got %v, want %v", sub.PeriodEnd, expected)
}
}
func TestAdvancePeriod_ZeroIntervalCount(t *testing.T) {
// IntervalCount=0 should default to 1
sub := &subscription.Subscription{}
p := makePlan("plan_z", "Z", 100, types.Monthly, 0, 0)
StartSubscription(sub, p)
expected := sub.PeriodStart.AddDate(0, 1, 0) // defaults to 1 month
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("zero interval count should default to 1: got %v, want %v", sub.PeriodEnd, expected)
}
}
// ---------------------------------------------------------------------------
// collector.go — CollectionResult struct
// ---------------------------------------------------------------------------
func TestCollectionResult_Struct(t *testing.T) {
r := CollectionResult{
Success: true,
CreditUsed: 500,
BalanceUsed: 300,
ProviderUsed: 200,
ProviderRef: "ch_abc123",
AmountCharged: 1000,
}
if !r.Success {
t.Fatal("expected success")
}
if r.CreditUsed+r.BalanceUsed+r.ProviderUsed != r.AmountCharged {
t.Fatalf("amounts should sum to charged: %d + %d + %d != %d",
r.CreditUsed, r.BalanceUsed, r.ProviderUsed, r.AmountCharged)
}
}
// ---------------------------------------------------------------------------
// metering.go — UsageSummary struct
// ---------------------------------------------------------------------------
func TestUsageSummary_Struct(t *testing.T) {
now := time.Now()
s := UsageSummary{
MeterId: "mtr_001",
MeterName: "API Calls",
AggregationType: "sum",
Value: 42000,
EventCount: 150,
PeriodStart: now.AddDate(0, -1, 0),
PeriodEnd: now,
}
if s.MeterId != "mtr_001" {
t.Fatalf("MeterId mismatch: %s", s.MeterId)
}
if s.Value != 42000 {
t.Fatalf("Value mismatch: %d", s.Value)
}
if s.EventCount != 150 {
t.Fatalf("EventCount mismatch: %d", s.EventCount)
}
if s.PeriodStart.After(s.PeriodEnd) {
t.Fatal("PeriodStart should be before PeriodEnd")
}
}
// ---------------------------------------------------------------------------
// balance.go — GetOrCreateCustomerBalance requires datastore
// ---------------------------------------------------------------------------
func TestGetOrCreateCustomerBalance_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: customerbalance.Query(db) needs live db")
}
func TestAdjustCustomerBalance_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: GetOrCreateCustomerBalance(db, ...) needs live db")
}
func TestApplyBalanceToInvoice_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: GetOrCreateCustomerBalance(db, ...) needs live db")
}
// ---------------------------------------------------------------------------
// tax.go — CalculateInvoiceTax with address requires datastore
// ---------------------------------------------------------------------------
func TestCalculateInvoiceTax_WithAddress_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: db.NewKey() + taxregion.Query(db) needs live db")
}
// ---------------------------------------------------------------------------
// events.go — EmitBillingEvent requires datastore
// ---------------------------------------------------------------------------
func TestEmitBillingEvent_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: billingevent.New(db) needs live db")
}
// ---------------------------------------------------------------------------
// aggregator.go — AggregateUsage requires datastore
// ---------------------------------------------------------------------------
func TestAggregateUsage_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: meter.Query(db) needs live db")
}
// ---------------------------------------------------------------------------
// lifecycle.go — RenewSubscription requires datastore
// ---------------------------------------------------------------------------
func TestRenewSubscription_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: billinginvoice.New(db) + AggregateUsage needs live db")
}
// ---------------------------------------------------------------------------
// Param struct construction tests
// ---------------------------------------------------------------------------
func TestCreatePaymentIntentParams_Defaults(t *testing.T) {
p := CreatePaymentIntentParams{
CustomerId: "cus_test",
Amount: 5000,
Currency: "usd",
}
if p.CaptureMethod != "" {
t.Fatal("CaptureMethod should default to empty")
}
if p.ConfirmationMethod != "" {
t.Fatal("ConfirmationMethod should default to empty")
}
if p.SetupFutureUsage != "" {
t.Fatal("SetupFutureUsage should default to empty")
}
}
func TestCreateSetupIntentParams_Defaults(t *testing.T) {
p := CreateSetupIntentParams{
CustomerId: "cus_test",
}
if p.PaymentMethodId != "" {
t.Fatal("PaymentMethodId should default to empty")
}
if p.Usage != "" {
t.Fatal("Usage should default to empty")
}
}
func TestCreateRefundParams_FullRefund(t *testing.T) {
p := CreateRefundParams{
PaymentIntentId: "pi_123",
Amount: 0, // 0 = full refund
Reason: "duplicate",
}
if p.Amount != 0 {
t.Fatal("Amount=0 should indicate full refund")
}
}
func TestCreateCreditNoteParams_LineItemTotal(t *testing.T) {
// Verify that when Amount is 0, line items should be summed
// (tested via CreateCreditNote, which requires datastore,
// but we verify the struct holds the data correctly)
p := CreateCreditNoteParams{
InvoiceId: "inv_001",
CustomerId: "cus_001",
Amount: 0,
Memo: "Goodwill credit",
}
if p.Amount != 0 {
t.Fatal("Amount should be 0 to indicate line-item-based calculation")
}
if p.Memo != "Goodwill credit" {
t.Fatalf("Memo mismatch: %s", p.Memo)
}
}
// ===========================================================================
// ADDITIONAL TESTS — boost coverage beyond 15.2%
// ===========================================================================
// ---------------------------------------------------------------------------
// intents.go — CreatePaymentIntent: edge cases
// ---------------------------------------------------------------------------
func TestCreatePaymentIntent_MissingCurrency_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: empty currency passes validation, paymentintent.New(db) needs live db")
}
func TestCreatePaymentIntent_ValidMinAmount_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: amount=1 passes validation, paymentintent.New(db) needs live db")
}
func TestCreatePaymentIntent_ValidLargeAmount_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: large amount passes validation, paymentintent.New(db) needs live db")
}
func TestCreatePaymentIntent_AllOptionalFields(t *testing.T) {
p := CreatePaymentIntentParams{
CustomerId: "cus_all",
Amount: 5000,
Currency: "eur",
PaymentMethodId: "pm_123",
CaptureMethod: "manual",
ConfirmationMethod: "automatic",
SetupFutureUsage: "off_session",
Description: "Test payment",
ReceiptEmail: "[email protected]",
InvoiceId: "inv_456",
}
if p.CaptureMethod != "manual" {
t.Fatal("CaptureMethod should be manual")
}
if p.ConfirmationMethod != "automatic" {
t.Fatal("ConfirmationMethod should be automatic")
}
if p.SetupFutureUsage != "off_session" {
t.Fatal("SetupFutureUsage should be off_session")
}
if p.Description != "Test payment" {
t.Fatal("Description mismatch")
}
if p.ReceiptEmail != "[email protected]" {
t.Fatal("ReceiptEmail mismatch")
}
if p.InvoiceId != "inv_456" {
t.Fatal("InvoiceId mismatch")
}
}
// ---------------------------------------------------------------------------
// intents.go — CreateSetupIntent: edge cases
// ---------------------------------------------------------------------------
func TestCreateSetupIntent_AllFields(t *testing.T) {
p := CreateSetupIntentParams{
CustomerId: "cus_setup",
PaymentMethodId: "pm_card_visa",
Usage: "off_session",
}
if p.Usage != "off_session" {
t.Fatal("Usage should be off_session")
}
if p.PaymentMethodId != "pm_card_visa" {
t.Fatal("PaymentMethodId mismatch")
}
}
// ---------------------------------------------------------------------------
// intents.go — ConfirmPaymentIntent requires datastore
// ---------------------------------------------------------------------------
func TestConfirmPaymentIntent_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: paymentintent.Confirm() + paymentmethod.GetById needs live db")
}
func TestCapturePaymentIntent_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: paymentintent.Capture() + pi.Update() needs live db")
}
func TestCancelPaymentIntent_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: pi.Cancel() + pi.Update() needs live db")
}
func TestConfirmSetupIntent_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: si.Confirm() + paymentmethod.GetById needs live db")
}
func TestCancelSetupIntent_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: si.Cancel() + si.Update() needs live db")
}
// ---------------------------------------------------------------------------
// lifecycle.go — StartSubscription: additional intervals and edge cases
// ---------------------------------------------------------------------------
func TestStartSubscription_DefaultInterval(t *testing.T) {
// Unknown interval should default to monthly
sub := &subscription.Subscription{}
p := makePlan("plan_custom", "Custom", 1500, "weekly", 2, 0)
StartSubscription(sub, p)
// Default case in advancePeriod treats unknown interval as monthly
expected := sub.PeriodStart.AddDate(0, 2, 0)
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("unknown interval should default to monthly: got %v, want %v", sub.PeriodEnd, expected)
}
}
func TestStartSubscription_SetsAllFields(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_full", "Full", 9900, types.Monthly, 1, 0)
StartSubscription(sub, p)
if sub.Plan.Name != "Full" {
t.Fatalf("Plan.Name mismatch: %s", sub.Plan.Name)
}
if sub.Plan.Price != 9900 {
t.Fatalf("Plan.Price mismatch: %d", sub.Plan.Price)
}
if string(sub.Plan.Currency) != "usd" {
t.Fatalf("Plan.Currency mismatch: %s", sub.Plan.Currency)
}
if sub.PlanId != "plan_full" {
t.Fatalf("PlanId mismatch: %s", sub.PlanId)
}
}
func TestStartSubscription_TrialDaysOne(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_trial1", "OneDayTrial", 500, types.Monthly, 1, 1)
StartSubscription(sub, p)
if sub.Status != subscription.Trialing {
t.Fatalf("expected Trialing, got %s", sub.Status)
}
expectedTrialEnd := sub.TrialStart.AddDate(0, 0, 1)
if !sub.TrialEnd.Equal(expectedTrialEnd) {
t.Fatalf("1-day trial end mismatch: got %v, want %v", sub.TrialEnd, expectedTrialEnd)
}
}
func TestStartSubscription_YearlyWithTrial(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_yearly_trial", "YearlyTrial", 12000, types.Yearly, 1, 30)
StartSubscription(sub, p)
if sub.Status != subscription.Trialing {
t.Fatalf("expected Trialing, got %s", sub.Status)
}
// Trial is 30 days
expectedTrialEnd := sub.TrialStart.AddDate(0, 0, 30)
if !sub.TrialEnd.Equal(expectedTrialEnd) {
t.Fatalf("trial end mismatch: got %v, want %v", sub.TrialEnd, expectedTrialEnd)
}
// Period after trial should be 1 year
expectedPeriodEnd := sub.PeriodStart.AddDate(1, 0, 0)
if !sub.PeriodEnd.Equal(expectedPeriodEnd) {
t.Fatalf("yearly period end mismatch: got %v, want %v", sub.PeriodEnd, expectedPeriodEnd)
}
}
func TestStartSubscription_MultiYearInterval(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_2y", "TwoYear", 50000, types.Yearly, 2, 0)
StartSubscription(sub, p)
expected := sub.PeriodStart.AddDate(2, 0, 0)
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("2-year period mismatch: got %v, want %v", sub.PeriodEnd, expected)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — CancelSubscription: additional states
// ---------------------------------------------------------------------------
func TestCancelSubscription_Trialing_Immediate(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Trialing}
if err := CancelSubscription(sub, false); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.Status != subscription.Canceled {
t.Fatalf("expected Canceled, got %s", sub.Status)
}
if !sub.Canceled {
t.Fatal("Canceled flag should be true")
}
if sub.Ended.IsZero() {
t.Fatal("Ended should be set for immediate cancel")
}
}
func TestCancelSubscription_Trialing_AtPeriodEnd(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Trialing}
if err := CancelSubscription(sub, true); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.EndCancel != true {
t.Fatal("EndCancel should be true")
}
if sub.CanceledAt.IsZero() {
t.Fatal("CanceledAt should be set")
}
// Status should remain trialing for deferred cancel
if sub.Status != subscription.Trialing {
t.Fatalf("expected status to remain Trialing for at-period-end, got %s", sub.Status)
}
}
func TestCancelSubscription_PastDue_Immediate(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.PastDue}
if err := CancelSubscription(sub, false); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.Status != subscription.Canceled {
t.Fatalf("expected Canceled, got %s", sub.Status)
}
}
func TestCancelSubscription_Unpaid(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Unpaid}
if err := CancelSubscription(sub, false); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.Status != subscription.Canceled {
t.Fatalf("expected Canceled, got %s", sub.Status)
}
}
func TestCancelSubscription_SetsTimestampClose(t *testing.T) {
before := time.Now()
sub := &subscription.Subscription{Status: subscription.Active}
_ = CancelSubscription(sub, false)
after := time.Now()
if sub.CanceledAt.Before(before) || sub.CanceledAt.After(after) {
t.Fatalf("CanceledAt should be between before and after: got %v", sub.CanceledAt)
}
if sub.Ended.Before(before) || sub.Ended.After(after) {
t.Fatalf("Ended should be between before and after: got %v", sub.Ended)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — ReactivateSubscription: additional cases
// ---------------------------------------------------------------------------
func TestReactivateSubscription_Trialing(t *testing.T) {
// Trialing with pending cancel
sub := &subscription.Subscription{
Status: subscription.Trialing,
EndCancel: true,
Canceled: true,
CanceledAt: time.Now(),
}
if err := ReactivateSubscription(sub); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.EndCancel {
t.Fatal("EndCancel should be cleared")
}
if sub.Canceled {
t.Fatal("Canceled should be cleared")
}
// Status should remain Trialing (not changed to Active)
if sub.Status != subscription.Trialing {
t.Fatalf("expected Trialing to be preserved, got %s", sub.Status)
}
}
func TestReactivateSubscription_PastDue(t *testing.T) {
sub := &subscription.Subscription{
Status: subscription.PastDue,
Canceled: true,
}
if err := ReactivateSubscription(sub); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Status stays PastDue (only Canceled -> Active transition happens)
if sub.Status != subscription.PastDue {
t.Fatalf("expected PastDue to be preserved, got %s", sub.Status)
}
if sub.Canceled {
t.Fatal("Canceled should be cleared")
}
}
func TestReactivateSubscription_ActiveNotCanceled(t *testing.T) {
// Reactivating an active sub that was never canceled — should be a no-op
sub := &subscription.Subscription{
Status: subscription.Active,
}
if err := ReactivateSubscription(sub); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.Status != subscription.Active {
t.Fatalf("expected Active, got %s", sub.Status)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — TransitionTrialToActive: all invalid states
// ---------------------------------------------------------------------------
func TestTransitionTrialToActive_PastDue(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.PastDue}
err := TransitionTrialToActive(sub)
if err == nil {
t.Fatal("expected error for PastDue subscription")
}
if !strings.Contains(err.Error(), "not trialing") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestTransitionTrialToActive_Unpaid(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Unpaid}
err := TransitionTrialToActive(sub)
if err == nil {
t.Fatal("expected error for Unpaid subscription")
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — ChangePlan: additional edge cases
// ---------------------------------------------------------------------------
func TestChangePlan_SamePlan_NoProration(t *testing.T) {
p := makePlan("plan_basic", "Basic", 1000, types.Monthly, 1, 0)
sub := &subscription.Subscription{
Plan: *p,
PlanId: "plan_basic",
}
item, err := ChangePlan(sub, p, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item != nil {
t.Fatal("expected nil proration item for same plan, no prorate")
}
if sub.PlanId != "plan_basic" {
t.Fatalf("planId should remain: %s", sub.PlanId)
}
}
func TestChangePlan_SamePlan_WithProration(t *testing.T) {
now := time.Now()
p := makePlan("plan_basic", "Basic", 3000, types.Monthly, 1, 0)
sub := &subscription.Subscription{
Plan: *p,
PlanId: "plan_basic",
PeriodStart: now.AddDate(0, 0, -15),
PeriodEnd: now.AddDate(0, 0, 15),
}
item, err := ChangePlan(sub, p, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Same plan proration should be approximately zero
if item != nil && (item.Amount > 1 || item.Amount < -1) {
t.Fatalf("same plan proration should be ~0, got %d", item.Amount)
}
}
func TestChangePlan_UpdatesPlanFields(t *testing.T) {
oldPlan := makePlan("plan_old", "Old", 1000, types.Monthly, 1, 0)
newPlan := makePlan("plan_new", "New", 2000, types.Yearly, 1, 0)
sub := &subscription.Subscription{
Plan: *oldPlan,
PlanId: "plan_old",
}
_, err := ChangePlan(sub, newPlan, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.PlanId != "plan_new" {
t.Fatalf("PlanId should be updated: %s", sub.PlanId)
}
if sub.Plan.Name != "New" {
t.Fatalf("Plan.Name should be updated: %s", sub.Plan.Name)
}
if sub.Plan.Price != 2000 {
t.Fatalf("Plan.Price should be updated: %d", sub.Plan.Price)
}
}
func TestChangePlan_ZeroPeriodDuration(t *testing.T) {
// PeriodStart == PeriodEnd → totalDays <= 0 → nil proration
now := time.Now()
old := makePlan("plan_a", "A", 1000, types.Monthly, 1, 0)
new_ := makePlan("plan_b", "B", 2000, types.Monthly, 1, 0)
sub := &subscription.Subscription{
Plan: *old,
PlanId: "plan_a",
PeriodStart: now,
PeriodEnd: now, // zero duration
}
item, err := ChangePlan(sub, new_, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item != nil {
t.Fatal("expected nil proration for zero-duration period")
}
// Plan should still be updated
if sub.PlanId != "plan_b" {
t.Fatalf("planId should be updated: %s", sub.PlanId)
}
}
func TestChangePlan_ExpiredPeriod(t *testing.T) {
// PeriodEnd < PeriodStart → totalDays negative → nil proration
now := time.Now()
old := makePlan("plan_old", "Old", 1000, types.Monthly, 1, 0)
new_ := makePlan("plan_new", "New", 2000, types.Monthly, 1, 0)
sub := &subscription.Subscription{
Plan: *old,
PlanId: "plan_old",
PeriodStart: now,
PeriodEnd: now.AddDate(0, 0, -5), // expired
}
item, err := ChangePlan(sub, new_, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item != nil {
t.Fatal("expected nil proration for expired period")
}
}
func TestChangePlan_ProrationDescription(t *testing.T) {
now := time.Now()
old := makePlan("plan_a", "Alpha", 4000, types.Monthly, 1, 0)
new_ := makePlan("plan_b", "Beta", 8000, types.Monthly, 1, 0)
sub := &subscription.Subscription{
Plan: *old,
PlanId: "plan_a",
PeriodStart: now.AddDate(0, 0, -10),
PeriodEnd: now.AddDate(0, 0, 20),
}
item, err := ChangePlan(sub, new_, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item == nil {
t.Fatal("expected proration item")
}
if !strings.Contains(item.Description, "Alpha") {
t.Fatalf("description should contain old plan name: %s", item.Description)
}
if !strings.Contains(item.Description, "Beta") {
t.Fatalf("description should contain new plan name: %s", item.Description)
}
if !strings.Contains(item.Description, "Proration") {
t.Fatalf("description should contain 'Proration': %s", item.Description)
}
}
func TestChangePlan_ProrationItemFields(t *testing.T) {
now := time.Now()
old := makePlan("plan_lo", "Lo", 2000, types.Monthly, 1, 0)
hi := makePlan("plan_hi", "Hi", 6000, types.Monthly, 1, 0)
sub := &subscription.Subscription{
Plan: *old,
PlanId: "plan_lo",
PeriodStart: now.AddDate(0, 0, -15),
PeriodEnd: now.AddDate(0, 0, 15),
}
item, _ := ChangePlan(sub, hi, true)
if item == nil {
t.Fatal("expected proration item")
}
if item.Type != billinginvoice.LineProration {
t.Fatalf("expected proration type, got %s", item.Type)
}
if item.PlanId != "plan_hi" {
t.Fatalf("proration item should reference new plan: %s", item.PlanId)
}
if item.PlanName != "Hi" {
t.Fatalf("proration item PlanName mismatch: %s", item.PlanName)
}
if item.PeriodEnd.IsZero() {
t.Fatal("proration item PeriodEnd should be set")
}
if item.PeriodStart.IsZero() {
t.Fatal("proration item PeriodStart should be set")
}
if !strings.HasPrefix(item.Id, "li_proration_") {
t.Fatalf("proration item Id should start with li_proration_: %s", item.Id)
}
}
func TestChangePlan_FreeToPaid(t *testing.T) {
now := time.Now()
free := makePlan("plan_free", "Free", 0, types.Monthly, 1, 0)
paid := makePlan("plan_paid", "Paid", 5000, types.Monthly, 1, 0)
sub := &subscription.Subscription{
Plan: *free,
PlanId: "plan_free",
PeriodStart: now.AddDate(0, 0, -15),
PeriodEnd: now.AddDate(0, 0, 15),
}
item, err := ChangePlan(sub, paid, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item == nil {
t.Fatal("expected proration item")
}
// Upgrading from free to paid: should be positive
if item.Amount <= 0 {
t.Fatalf("expected positive amount for free->paid upgrade, got %d", item.Amount)
}
}
func TestChangePlan_PaidToFree(t *testing.T) {
now := time.Now()
paid := makePlan("plan_paid", "Paid", 5000, types.Monthly, 1, 0)
free := makePlan("plan_free", "Free", 0, types.Monthly, 1, 0)
sub := &subscription.Subscription{
Plan: *paid,
PlanId: "plan_paid",
PeriodStart: now.AddDate(0, 0, -15),
PeriodEnd: now.AddDate(0, 0, 15),
}
item, err := ChangePlan(sub, free, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if item == nil {
t.Fatal("expected proration item")
}
// Downgrading from paid to free: should be negative
if item.Amount >= 0 {
t.Fatalf("expected negative amount for paid->free downgrade, got %d", item.Amount)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — advancePeriod: additional edge cases
// ---------------------------------------------------------------------------
func TestAdvancePeriod_NegativeIntervalCount(t *testing.T) {
// Negative interval count should default to 1
sub := &subscription.Subscription{}
p := makePlan("plan_neg", "Neg", 100, types.Monthly, -1, 0)
StartSubscription(sub, p)
expected := sub.PeriodStart.AddDate(0, 1, 0) // defaults to 1
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("negative interval count should default to 1: got %v, want %v", sub.PeriodEnd, expected)
}
}
func TestAdvancePeriod_LargeIntervalCount(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_12m", "12Month", 100, types.Monthly, 12, 0)
StartSubscription(sub, p)
expected := sub.PeriodStart.AddDate(0, 12, 0)
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("12-month period mismatch: got %v, want %v", sub.PeriodEnd, expected)
}
}
// ---------------------------------------------------------------------------
// tax.go — TaxLine: additional struct tests
// ---------------------------------------------------------------------------
func TestTaxLine_Inclusive(t *testing.T) {
tl := TaxLine{
TaxRateId: "txr_inc",
Description: "VAT",
Amount: 2000,
Rate: 0.20,
Inclusive: true,
Jurisdiction: "GB",
}
if !tl.Inclusive {
t.Fatal("Inclusive should be true")
}
if tl.Rate != 0.20 {
t.Fatalf("Rate mismatch: %f", tl.Rate)
}
}
func TestTaxLine_ZeroRate(t *testing.T) {
tl := TaxLine{
TaxRateId: "txr_zero",
Description: "Zero-rated",
Amount: 0,
Rate: 0.0,
Inclusive: false,
Jurisdiction: "US-OR",
}
if tl.Amount != 0 {
t.Fatalf("Amount should be 0 for zero-rated tax: %d", tl.Amount)
}
if tl.Rate != 0.0 {
t.Fatalf("Rate should be 0: %f", tl.Rate)
}
}
func TestTaxLine_MultipleTaxes(t *testing.T) {
// Simulate stacking state + county taxes
lines := []TaxLine{
{TaxRateId: "txr_state", Description: "State Tax", Amount: 600, Rate: 0.06, Jurisdiction: "US-TX"},
{TaxRateId: "txr_county", Description: "County Tax", Amount: 200, Rate: 0.02, Jurisdiction: "US-TX-HARRIS"},
}
var total int64
for _, l := range lines {
total += l.Amount
}
if total != 800 {
t.Fatalf("total tax should be 800, got %d", total)
}
if len(lines) != 2 {
t.Fatalf("expected 2 tax lines, got %d", len(lines))
}
}
// ---------------------------------------------------------------------------
// collector.go — CollectionResult: additional struct tests
// ---------------------------------------------------------------------------
func TestCollectionResult_Failure(t *testing.T) {
r := CollectionResult{
Success: false,
AmountCharged: 0,
Error: "card_declined",
}
if r.Success {
t.Fatal("expected failure")
}
if r.Error != "card_declined" {
t.Fatalf("Error mismatch: %s", r.Error)
}
if r.AmountCharged != 0 {
t.Fatalf("AmountCharged should be 0 for failure: %d", r.AmountCharged)
}
}
func TestCollectionResult_CreditOnly(t *testing.T) {
r := CollectionResult{
Success: true,
CreditUsed: 5000,
BalanceUsed: 0,
ProviderUsed: 0,
AmountCharged: 5000,
}
if !r.Success {
t.Fatal("expected success")
}
if r.CreditUsed != r.AmountCharged {
t.Fatal("credit-only payment should have CreditUsed == AmountCharged")
}
if r.ProviderRef != "" {
t.Fatal("credit-only should have no provider ref")
}
}
func TestCollectionResult_BalanceOnly(t *testing.T) {
r := CollectionResult{
Success: true,
CreditUsed: 0,
BalanceUsed: 3000,
ProviderUsed: 0,
AmountCharged: 3000,
}
if r.BalanceUsed != r.AmountCharged {
t.Fatal("balance-only payment should have BalanceUsed == AmountCharged")
}
}
func TestCollectionResult_ProviderOnly(t *testing.T) {
r := CollectionResult{
Success: true,
CreditUsed: 0,
BalanceUsed: 0,
ProviderUsed: 10000,
ProviderRef: "ch_stripe_123",
AmountCharged: 10000,
}
if r.ProviderUsed != r.AmountCharged {
t.Fatal("provider-only payment should have ProviderUsed == AmountCharged")
}
if r.ProviderRef != "ch_stripe_123" {
t.Fatalf("ProviderRef mismatch: %s", r.ProviderRef)
}
}
func TestCollectionResult_ZeroAmount(t *testing.T) {
// e.g. $0 invoice (100% discount)
r := CollectionResult{
Success: true,
AmountCharged: 0,
}
if !r.Success {
t.Fatal("zero-amount collection should succeed")
}
}
// ---------------------------------------------------------------------------
// metering.go — UsageSummary: additional tests
// ---------------------------------------------------------------------------
func TestUsageSummary_ZeroValues(t *testing.T) {
s := UsageSummary{
MeterId: "mtr_empty",
MeterName: "Empty Meter",
AggregationType: "sum",
Value: 0,
EventCount: 0,
}
if s.Value != 0 {
t.Fatalf("Value should be 0: %d", s.Value)
}
if s.EventCount != 0 {
t.Fatalf("EventCount should be 0: %d", s.EventCount)
}
}
func TestUsageSummary_CountAggregation(t *testing.T) {
s := UsageSummary{
MeterId: "mtr_count",
MeterName: "Request Count",
AggregationType: "count",
Value: 1000,
EventCount: 1000,
}
if s.AggregationType != "count" {
t.Fatalf("AggregationType mismatch: %s", s.AggregationType)
}
// For count aggregation, Value should equal EventCount
if s.Value != s.EventCount {
t.Fatalf("count aggregation: Value (%d) should match EventCount (%d)", s.Value, s.EventCount)
}
}
func TestUsageSummary_LastAggregation(t *testing.T) {
s := UsageSummary{
MeterId: "mtr_gauge",
MeterName: "Temperature",
AggregationType: "last",
Value: 72,
EventCount: 500,
}
if s.AggregationType != "last" {
t.Fatalf("AggregationType mismatch: %s", s.AggregationType)
}
// For last aggregation, Value is the last event's value, not related to count
if s.Value == s.EventCount {
// This is fine but unlikely — just documenting the semantics
}
}
func TestUsageSummary_PeriodBoundary(t *testing.T) {
now := time.Now()
start := now.AddDate(0, -1, 0)
end := now
s := UsageSummary{
MeterId: "mtr_boundary",
PeriodStart: start,
PeriodEnd: end,
}
if s.PeriodEnd.Before(s.PeriodStart) {
t.Fatal("PeriodEnd should be after PeriodStart")
}
duration := s.PeriodEnd.Sub(s.PeriodStart)
if duration.Hours() < 24*28 { // at least 28 days for ~1 month
t.Fatalf("period should be roughly 1 month, got %v", duration)
}
}
// ---------------------------------------------------------------------------
// refunds.go — CreateRefundParams: additional param tests
// ---------------------------------------------------------------------------
func TestCreateRefundParams_PartialRefund(t *testing.T) {
p := CreateRefundParams{
PaymentIntentId: "pi_abc",
Amount: 500,
Reason: "requested_by_customer",
}
if p.Amount != 500 {
t.Fatal("Amount should be 500 for partial refund")
}
if p.Reason != "requested_by_customer" {
t.Fatalf("Reason mismatch: %s", p.Reason)
}
}
func TestCreateRefundParams_FraudulentReason(t *testing.T) {
p := CreateRefundParams{
PaymentIntentId: "pi_fraud",
Amount: 0,
Reason: "fraudulent",
}
if p.Reason != "fraudulent" {
t.Fatalf("Reason mismatch: %s", p.Reason)
}
}
func TestCreateRefundParams_DuplicateReason(t *testing.T) {
p := CreateRefundParams{
InvoiceId: "inv_dup",
Reason: "duplicate",
}
if p.Reason != "duplicate" {
t.Fatalf("Reason mismatch: %s", p.Reason)
}
if p.InvoiceId != "inv_dup" {
t.Fatalf("InvoiceId mismatch: %s", p.InvoiceId)
}
}
func TestCreateRefundParams_BothIds(t *testing.T) {
// Having both IDs set — PaymentIntentId takes priority in the code
p := CreateRefundParams{
PaymentIntentId: "pi_both",
InvoiceId: "inv_both",
Amount: 1000,
Reason: "requested_by_customer",
}
if p.PaymentIntentId == "" {
t.Fatal("PaymentIntentId should be set")
}
if p.InvoiceId == "" {
t.Fatal("InvoiceId should be set")
}
}
// ---------------------------------------------------------------------------
// refunds.go — CreateCreditNoteParams: additional tests
// ---------------------------------------------------------------------------
func TestCreateCreditNoteParams_WithLineItems(t *testing.T) {
p := CreateCreditNoteParams{
InvoiceId: "inv_li",
CustomerId: "cus_li",
LineItems: []credit.CreditNoteLineItem{
{Description: "API calls overage", Amount: 500, Quantity: 100, UnitPrice: 5},
{Description: "Storage credit", Amount: 200, Quantity: 1, UnitPrice: 200},
},
Memo: "Usage credit",
}
if len(p.LineItems) != 2 {
t.Fatalf("expected 2 line items, got %d", len(p.LineItems))
}
var total int64
for _, li := range p.LineItems {
total += li.Amount
}
if total != 700 {
t.Fatalf("line item total should be 700, got %d", total)
}
}
func TestCreateCreditNoteParams_OutOfBandAmount(t *testing.T) {
p := CreateCreditNoteParams{
InvoiceId: "inv_oob",
OutOfBandAmount: 1500,
}
if p.OutOfBandAmount != 1500 {
t.Fatalf("OutOfBandAmount mismatch: %d", p.OutOfBandAmount)
}
}
func TestCreateCreditNoteParams_AllReasons(t *testing.T) {
reasons := []string{"duplicate", "fraudulent", "order_change", "product_unsatisfactory"}
for _, reason := range reasons {
p := CreateCreditNoteParams{
InvoiceId: "inv_reason",
Reason: reason,
}
if p.Reason != reason {
t.Fatalf("Reason mismatch for %s: %s", reason, p.Reason)
}
}
}
// ---------------------------------------------------------------------------
// metering.go — IngestUsageEvent: validation edge cases
// ---------------------------------------------------------------------------
func TestIngestUsageEvent_ZeroValue_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: zero value passes validation, meter.NewEvent(db) needs live db")
}
func TestIngestUsageEvent_NegativeValue_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: negative value passes validation, meter.NewEvent(db) needs live db")
}
func TestIngestUsageEvent_WhitespaceMeterId_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: whitespace meterId passes validation (only checks empty string), meter.NewEvent(db) needs live db")
}
// ---------------------------------------------------------------------------
// metering.go — datastore-required skip tests
// ---------------------------------------------------------------------------
func TestIngestUsageEventBatch_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: IngestUsageEvent calls meter.NewEvent(db)")
}
func TestGetUsageSummary_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: meter.New(db).GetById needs live db")
}
func TestAggregateItemUsage_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: meter.New(db).GetById + watermark queries need live db")
}
func TestCreateWatermark_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: usagewatermark.New(db).Create needs live db")
}
func TestCheckThreshold_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: meter.New(db).GetById + event queries need live db")
}
// ---------------------------------------------------------------------------
// collector.go — CollectInvoice requires datastore
// ---------------------------------------------------------------------------
func TestCollectInvoice_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: the prepaid draw reads credit grants and the ledger")
}
// ---------------------------------------------------------------------------
// lifecycle.go — full lifecycle state machine integration
// ---------------------------------------------------------------------------
func TestLifecycle_FullFlow_NoTrial(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_flow", "Flow", 2000, types.Monthly, 1, 0)
// 1. Start
StartSubscription(sub, p)
if sub.Status != subscription.Active {
t.Fatalf("step 1: expected Active, got %s", sub.Status)
}
// 2. Cancel at period end
if err := CancelSubscription(sub, true); err != nil {
t.Fatalf("step 2: %v", err)
}
if !sub.EndCancel {
t.Fatal("step 2: EndCancel should be true")
}
// 3. Reactivate
if err := ReactivateSubscription(sub); err != nil {
t.Fatalf("step 3: %v", err)
}
if sub.EndCancel {
t.Fatal("step 3: EndCancel should be false")
}
// 4. Change plan
newPlan := makePlan("plan_pro", "Pro", 5000, types.Monthly, 1, 0)
item, err := ChangePlan(sub, newPlan, true)
if err != nil {
t.Fatalf("step 4: %v", err)
}
if item == nil {
t.Fatal("step 4: expected proration item")
}
if sub.PlanId != "plan_pro" {
t.Fatalf("step 4: planId should be plan_pro, got %s", sub.PlanId)
}
// 5. Cancel immediately
if err := CancelSubscription(sub, false); err != nil {
t.Fatalf("step 5: %v", err)
}
if sub.Status != subscription.Canceled {
t.Fatalf("step 5: expected Canceled, got %s", sub.Status)
}
// 6. Cannot cancel again
err = CancelSubscription(sub, false)
if err == nil {
t.Fatal("step 6: should not be able to cancel again")
}
}
func TestLifecycle_FullFlow_WithTrial(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_trial_flow", "TrialFlow", 3000, types.Monthly, 1, 7)
// 1. Start with trial
StartSubscription(sub, p)
if sub.Status != subscription.Trialing {
t.Fatalf("step 1: expected Trialing, got %s", sub.Status)
}
// 2. Transition trial to active
if err := TransitionTrialToActive(sub); err != nil {
t.Fatalf("step 2: %v", err)
}
if sub.Status != subscription.Active {
t.Fatalf("step 2: expected Active, got %s", sub.Status)
}
// 3. Cannot transition again
err := TransitionTrialToActive(sub)
if err == nil {
t.Fatal("step 3: should not transition non-trialing to active")
}
// 4. Cancel at period end
if err := CancelSubscription(sub, true); err != nil {
t.Fatalf("step 4: %v", err)
}
// 5. Reactivate
if err := ReactivateSubscription(sub); err != nil {
t.Fatalf("step 5: %v", err)
}
// 6. Immediate cancel
if err := CancelSubscription(sub, false); err != nil {
t.Fatalf("step 6: %v", err)
}
if sub.Status != subscription.Canceled {
t.Fatalf("step 6: expected Canceled, got %s", sub.Status)
}
}
func TestLifecycle_CancelDuringTrial(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_trial_cancel", "TrialCancel", 3000, types.Monthly, 1, 14)
StartSubscription(sub, p)
if sub.Status != subscription.Trialing {
t.Fatalf("expected Trialing, got %s", sub.Status)
}
// Cancel immediately during trial
if err := CancelSubscription(sub, false); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.Status != subscription.Canceled {
t.Fatalf("expected Canceled, got %s", sub.Status)
}
if !sub.Canceled {
t.Fatal("Canceled flag should be true")
}
}
func TestLifecycle_ReactivateThenCancel(t *testing.T) {
sub := &subscription.Subscription{
Status: subscription.Canceled,
Canceled: true,
// Ended is zero — eligible for reactivation
}
// Reactivate
if err := ReactivateSubscription(sub); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.Status != subscription.Active {
t.Fatalf("expected Active, got %s", sub.Status)
}
// Cancel again
if err := CancelSubscription(sub, false); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.Status != subscription.Canceled {
t.Fatalf("expected Canceled, got %s", sub.Status)
}
}
// ---------------------------------------------------------------------------
// makePlan helper — verify helper produces correct plans
// ---------------------------------------------------------------------------
func TestMakePlan_Fields(t *testing.T) {
p := makePlan("plan_test", "TestPlan", 4999, types.Yearly, 2, 30)
if p.Id() != "plan_test" {
t.Fatalf("Id mismatch: %s", p.Id())
}
if p.Name != "TestPlan" {
t.Fatalf("Name mismatch: %s", p.Name)
}
if int64(p.Price) != 4999 {
t.Fatalf("Price mismatch: %d", p.Price)
}
if p.Interval != types.Yearly {
t.Fatalf("Interval mismatch: %s", p.Interval)
}
if p.IntervalCount != 2 {
t.Fatalf("IntervalCount mismatch: %d", p.IntervalCount)
}
if p.TrialPeriodDays != 30 {
t.Fatalf("TrialPeriodDays mismatch: %d", p.TrialPeriodDays)
}
if string(p.Currency) != "usd" {
t.Fatalf("Currency mismatch: %s", p.Currency)
}
}
// ===========================================================================
// HIGH-COVERAGE TESTS — target 95%+ coverage
// ===========================================================================
// ---------------------------------------------------------------------------
// intents.go — CreatePaymentIntent boundary conditions
// ---------------------------------------------------------------------------
func TestCreatePaymentIntent_ExactlyOneAmount(t *testing.T) {
// Amount=1 is the minimum valid amount, but needs datastore
// Verify it passes validation (panics at nil db, not at validation)
defer func() {
r := recover()
if r == nil {
t.Fatal("expected panic from nil datastore, not validation error")
}
}()
_, _ = CreatePaymentIntent(nil, CreatePaymentIntentParams{
CustomerId: "cus_min",
Amount: 1,
Currency: "usd",
})
}
func TestCreatePaymentIntent_BothValidations(t *testing.T) {
// Both invalid: amount=0 AND missing customerId
// Should return the first validation error (amount)
_, err := CreatePaymentIntent(nil, CreatePaymentIntentParams{
Amount: 0,
Currency: "usd",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "amount must be positive") {
t.Fatalf("should fail on amount first: %s", err)
}
// Now with valid amount but missing customer
_, err = CreatePaymentIntent(nil, CreatePaymentIntentParams{
Amount: 100,
Currency: "usd",
})
if err == nil {
t.Fatal("expected error for missing customerId")
}
if !strings.Contains(err.Error(), "customerId is required") {
t.Fatalf("should fail on customerId: %s", err)
}
}
func TestCreatePaymentIntent_LargeAmount(t *testing.T) {
_, err := CreatePaymentIntent(nil, CreatePaymentIntentParams{
Amount: -9223372036854775808, // int64 min
Currency: "usd",
})
if err == nil {
t.Fatal("expected error for negative amount")
}
}
// ---------------------------------------------------------------------------
// intents.go — CreateSetupIntent boundary conditions
// ---------------------------------------------------------------------------
func TestCreateSetupIntent_EmptyStringCustomerId(t *testing.T) {
_, err := CreateSetupIntent(nil, CreateSetupIntentParams{
CustomerId: "",
})
if err == nil {
t.Fatal("expected error for empty customerId")
}
if !strings.Contains(err.Error(), "customerId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
// ---------------------------------------------------------------------------
// refunds.go — CreateRefund boundary conditions
// ---------------------------------------------------------------------------
func TestCreateRefund_EmptyBothIds(t *testing.T) {
_, err := CreateRefund(nil, nil, CreateRefundParams{
PaymentIntentId: "",
InvoiceId: "",
Amount: 500,
Reason: "duplicate",
}, nil)
if err == nil {
t.Fatal("expected error when both IDs empty")
}
if !strings.Contains(err.Error(), "either paymentIntentId or invoiceId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestCreateRefund_AllReasons(t *testing.T) {
reasons := []string{"duplicate", "fraudulent", "requested_by_customer", ""}
for _, reason := range reasons {
_, err := CreateRefund(nil, nil, CreateRefundParams{
Reason: reason,
}, nil)
if err == nil {
t.Fatalf("expected error for reason=%q (missing IDs)", reason)
}
if !strings.Contains(err.Error(), "either paymentIntentId or invoiceId is required") {
t.Fatalf("unexpected error for reason=%q: %s", reason, err)
}
}
}
// ---------------------------------------------------------------------------
// refunds.go — CreateCreditNote boundary conditions
// ---------------------------------------------------------------------------
func TestCreateCreditNote_EmptyInvoiceId(t *testing.T) {
_, err := CreateCreditNote(nil, CreateCreditNoteParams{
InvoiceId: "",
Amount: 100,
})
if err == nil {
t.Fatal("expected error for empty invoiceId")
}
if !strings.Contains(err.Error(), "invoiceId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestCreateCreditNoteParams_AmountVsLineItems(t *testing.T) {
// When Amount > 0, it takes precedence over line item totals
p := CreateCreditNoteParams{
InvoiceId: "inv_mixed",
Amount: 5000,
LineItems: []credit.CreditNoteLineItem{
{Amount: 100},
{Amount: 200},
},
OutOfBandAmount: 300,
}
if p.Amount != 5000 {
t.Fatal("explicit Amount should be 5000")
}
// Line item total would be 600, but Amount=5000 overrides
var liTotal int64
for _, li := range p.LineItems {
liTotal += li.Amount
}
liTotal += p.OutOfBandAmount
if liTotal != 600 {
t.Fatalf("line item total should be 600, got %d", liTotal)
}
}
// ---------------------------------------------------------------------------
// metering.go — IngestUsageEvent validation
// ---------------------------------------------------------------------------
func TestIngestUsageEvent_EmptyMeterId(t *testing.T) {
_, _, err := IngestUsageEvent(nil, "", "user_x", 100, "key_1", time.Now(), nil)
if err == nil {
t.Fatal("expected error for empty meterId")
}
if !strings.Contains(err.Error(), "meterId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestIngestUsageEvent_EmptyUserId(t *testing.T) {
_, _, err := IngestUsageEvent(nil, "meter_x", "", 100, "key_1", time.Now(), nil)
if err == nil {
t.Fatal("expected error for empty userId")
}
if !strings.Contains(err.Error(), "userId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestIngestUsageEvent_BothEmpty(t *testing.T) {
_, _, err := IngestUsageEvent(nil, "", "", 100, "", time.Now(), nil)
if err == nil {
t.Fatal("expected error")
}
// meterId is checked first
if !strings.Contains(err.Error(), "meterId is required") {
t.Fatalf("unexpected error: %s", err)
}
}
// ---------------------------------------------------------------------------
// metering.go — IngestUsageEventBatch validation edge cases
// ---------------------------------------------------------------------------
func TestIngestUsageEventBatch_EmptyBatch(t *testing.T) {
created, dups, err := IngestUsageEventBatch(nil, nil)
if err != nil {
t.Fatalf("empty batch should not error: %v", err)
}
if created != 0 || dups != 0 {
t.Fatalf("empty batch should return 0,0: got %d,%d", created, dups)
}
}
func TestIngestUsageEventBatch_ValidationError(t *testing.T) {
events := []struct {
MeterId string
UserId string
Value int64
Idempotency string
Timestamp time.Time
Dimensions map[string]interface{}
}{
{MeterId: "", UserId: "user_1", Value: 10}, // will fail validation
}
created, dups, err := IngestUsageEventBatch(nil, events)
if err == nil {
t.Fatal("expected error from batch with invalid event")
}
if created != 0 {
t.Fatalf("expected 0 created, got %d", created)
}
if dups != 0 {
t.Fatalf("expected 0 dups, got %d", dups)
}
}
// ---------------------------------------------------------------------------
// metering.go — AggregateItemUsage validation
// ---------------------------------------------------------------------------
func TestAggregateItemUsage_NoMeter(t *testing.T) {
item := &subscriptionitem.SubscriptionItem{
MeterId: "",
}
_, _, err := AggregateItemUsage(nil, item, time.Now(), time.Now())
if err == nil {
t.Fatal("expected error for item with no meter")
}
if !strings.Contains(err.Error(), "subscription item has no meter") {
t.Fatalf("unexpected error: %s", err)
}
}
// ---------------------------------------------------------------------------
// metering.go — CheckThreshold validation
// ---------------------------------------------------------------------------
func TestCheckThreshold_NoMeter(t *testing.T) {
item := &subscriptionitem.SubscriptionItem{
MeterId: "",
}
exceeded, value, err := CheckThreshold(nil, item, 100)
if err == nil {
t.Fatal("expected error for item with no meter")
}
if !strings.Contains(err.Error(), "subscription item has no meter") {
t.Fatalf("unexpected error: %s", err)
}
if exceeded {
t.Fatal("should not report exceeded on error")
}
if value != 0 {
t.Fatalf("value should be 0 on error, got %d", value)
}
}
// ---------------------------------------------------------------------------
// collector.go — CollectInvoice validation
// ---------------------------------------------------------------------------
func TestCollectInvoice_NonOpenInvoice(t *testing.T) {
statuses := []billinginvoice.Status{
billinginvoice.Draft,
billinginvoice.Paid,
billinginvoice.Void,
billinginvoice.Uncollectible,
}
for _, status := range statuses {
inv := &billinginvoice.BillingInvoice{}
inv.Status = status
_, err := CollectInvoice(nil, nil, inv, nil, nil)
if err == nil {
t.Fatalf("expected error for status=%s", status)
}
if !strings.Contains(err.Error(), "invoice must be open to collect") {
t.Fatalf("unexpected error for status=%s: %s", status, err)
}
}
}
func TestCollectInvoice_ZeroAmountDue(t *testing.T) {
inv := &billinginvoice.BillingInvoice{}
inv.Status = billinginvoice.Open
inv.AmountDue = 0
result, err := CollectInvoice(nil, nil, inv, nil, nil)
if err != nil {
t.Fatalf("zero amount should succeed: %v", err)
}
if !result.Success {
t.Fatal("zero amount should be successful")
}
if result.AmountCharged != 0 {
t.Fatalf("expected 0 charged, got %d", result.AmountCharged)
}
}
func TestCollectInvoice_PrepaidCoversInFull(t *testing.T) {
inv := &billinginvoice.BillingInvoice{}
inv.Status = billinginvoice.Open
inv.AmountDue = 1000
inv.UserId = "user_credit"
inv.Id_ = "inv_user_credit" // every invoice the collector sees is stored
pre := &purse{credit: 1000}
result, err := CollectInvoice(context.Background(), nil, inv, pre, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Success {
t.Fatal("expected success when credits cover full amount")
}
if result.CreditUsed != 1000 {
t.Fatalf("expected 1000 credit used, got %d", result.CreditUsed)
}
if result.AmountCharged != 1000 {
t.Fatalf("expected 1000 charged, got %d", result.AmountCharged)
}
if inv.CreditApplied != 1000 {
t.Fatalf("expected inv.CreditApplied=1000, got %d", inv.CreditApplied)
}
if inv.AttemptCount != 1 {
t.Fatalf("expected AttemptCount=1, got %d", inv.AttemptCount)
}
if inv.LastAttemptAt.IsZero() {
t.Fatal("LastAttemptAt should be set")
}
}
func TestCollectInvoice_PrepaidUnreadable(t *testing.T) {
inv := &billinginvoice.BillingInvoice{}
inv.Status = billinginvoice.Open
inv.AmountDue = 500
inv.UserId = "user_err"
inv.Id_ = "inv_user_err" // every invoice the collector sees is stored
// An unreadable balance is unknown, not zero: it covers nothing, nothing is
// drawn, and with no card the attempt fails and says why.
pre := &purse{credit: 500, readErr: fmt.Errorf("ledger unavailable")}
result, err := CollectInvoice(context.Background(), nil, inv, pre, nil)
if err != nil {
t.Fatalf("an unreadable balance must be non-fatal: %v", err)
}
if result.Success {
t.Fatal("expected failure when the balance cannot be read and there is no card")
}
if result.CreditUsed != 0 || len(pre.draws) != 0 {
t.Fatalf("drew %v (credit used %d); nothing may be drawn on an unreadable balance", pre.draws, result.CreditUsed)
}
if !strings.Contains(result.Error, "unreadable") {
t.Fatalf("expected the read failure in the error, got: %s", result.Error)
}
}
// TestCollectInvoice_ShortPrepaidDrawsNothing: prepaid money covers 600 of 1000
// and there is no card. The attempt fails and takes NOTHING — a partial draw
// against an invoice that stays unpaid is money the next attempt takes again.
func TestCollectInvoice_ShortPrepaidDrawsNothing(t *testing.T) {
inv := &billinginvoice.BillingInvoice{}
inv.Status = billinginvoice.Open
inv.AmountDue = 1000
inv.UserId = "user_partial"
inv.Id_ = "inv_user_partial" // every invoice the collector sees is stored
pre := &purse{credit: 600}
result, err := CollectInvoice(context.Background(), nil, inv, pre, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Success {
t.Fatal("expected failure with 600 of 1000 and no card")
}
if result.CreditUsed != 0 || result.AmountCharged != 0 || pre.credit != 600 || len(pre.draws) != 0 {
t.Fatalf("credit used %d, charged %d, credit left %d, draws %v; a short collection takes nothing",
result.CreditUsed, result.AmountCharged, pre.credit, pre.draws)
}
if inv.Status != billinginvoice.Open || inv.AmountPaid != 0 {
t.Fatalf("invoice %s paid %d, want open and unpaid", inv.Status, inv.AmountPaid)
}
if !strings.Contains(result.Error, "insufficient funds: 1000 cents") {
t.Fatalf("expected the whole amount owed in the error, got: %s", result.Error)
}
}
func TestCollectInvoice_NilPrepaid(t *testing.T) {
inv := &billinginvoice.BillingInvoice{}
inv.Status = billinginvoice.Open
inv.AmountDue = 500
result, err := CollectInvoice(context.Background(), nil, inv, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Success {
t.Fatal("expected failure with no prepaid money, no card and a positive amount")
}
if result.CreditUsed != 0 {
t.Fatalf("expected 0 credit, got %d", result.CreditUsed)
}
}
// ---------------------------------------------------------------------------
// collector.go — CollectionResult method field
// ---------------------------------------------------------------------------
func TestCollectInvoice_PaymentMethod_CreditOnly(t *testing.T) {
inv := &billinginvoice.BillingInvoice{}
inv.Status = billinginvoice.Open
inv.AmountDue = 100
inv.UserId = "user_method"
inv.Id_ = "inv_user_method" // every invoice the collector sees is stored
result, err := CollectInvoice(context.Background(), nil, inv, &purse{credit: 100}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Success {
t.Fatal("expected success")
}
if inv.AmountPaid != 100 || inv.PaymentMethod != "credit" {
t.Fatalf("expected AmountPaid=100 by credit, got %d by %q", inv.AmountPaid, inv.PaymentMethod)
}
}
// TestCollectInvoice_RetryCollectsTheRemainder: what one attempt collected stays
// collected, and the next attempt asks only for what is still owed.
func TestCollectInvoice_RetryCollectsTheRemainder(t *testing.T) {
inv := &billinginvoice.BillingInvoice{}
inv.Status = billinginvoice.Open
inv.AmountDue = 2500
inv.AmountPaid = 1000 // an earlier attempt's card payment
inv.UserId = "user_retry"
inv.Id_ = "inv_user_retry" // every invoice the collector sees is stored
pre := &purse{balance: 5000}
result, err := CollectInvoice(context.Background(), nil, inv, pre, nil)
if err != nil || !result.Success {
t.Fatalf("result=%+v err=%v, want success", result, err)
}
if result.BalanceUsed != 1500 || pre.balance != 3500 {
t.Fatalf("drew %d leaving %d, want the 1500 still owed and 3500 left", result.BalanceUsed, pre.balance)
}
if inv.AmountPaid != 2500 || inv.PaymentMethod != "balance" {
t.Fatalf("invoice paid %d by %q, want 2500 by balance", inv.AmountPaid, inv.PaymentMethod)
}
}
// ---------------------------------------------------------------------------
// tax.go — CalculateInvoiceTax edge cases
// ---------------------------------------------------------------------------
func TestCalculateInvoiceTax_EmptyAddress_RequiresDatastore(t *testing.T) {
t.Skip("requires datastore: db.NewKey() needed for tax region query")
}
func TestCalculateInvoiceTax_NilAddressReturnsZero(t *testing.T) {
lines, total, err := CalculateInvoiceTax(nil, &billinginvoice.BillingInvoice{}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if total != 0 {
t.Fatalf("expected 0 tax for nil address, got %d", total)
}
if len(lines) != 0 {
t.Fatalf("expected 0 lines for nil address, got %d", len(lines))
}
}
func TestCalculateInvoiceTax_NilInvoiceNilAddress(t *testing.T) {
lines, total, err := CalculateInvoiceTax(nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if total != 0 {
t.Fatalf("expected 0 tax, got %d", total)
}
if len(lines) != 0 {
t.Fatalf("expected 0 lines, got %d", len(lines))
}
}
// ---------------------------------------------------------------------------
// balance.go — default currency
// ---------------------------------------------------------------------------
func TestGetOrCreateCustomerBalance_DefaultCurrency(t *testing.T) {
// This function defaults empty currency to "usd"
// It requires datastore so we just verify the function exists and docs
t.Skip("requires datastore")
}
// ---------------------------------------------------------------------------
// lifecycle.go — StartSubscription: verify all plan fields are copied
// ---------------------------------------------------------------------------
func TestStartSubscription_CopiesPlanPrice(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_copy", "CopyTest", 7777, types.Monthly, 1, 0)
StartSubscription(sub, p)
if sub.Plan.Price != 7777 {
t.Fatalf("Plan.Price = %d, want 7777", sub.Plan.Price)
}
if sub.Plan.Name != "CopyTest" {
t.Fatalf("Plan.Name = %s, want CopyTest", sub.Plan.Name)
}
if sub.PlanId != "plan_copy" {
t.Fatalf("PlanId = %s, want plan_copy", sub.PlanId)
}
if sub.Start.IsZero() {
t.Fatal("Start should be set")
}
}
func TestStartSubscription_TrialPeriodEnd(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_tp", "TrialP", 1000, types.Monthly, 1, 7)
StartSubscription(sub, p)
// PeriodStart == TrialEnd
if !sub.PeriodStart.Equal(sub.TrialEnd) {
t.Fatalf("PeriodStart should equal TrialEnd: %v != %v", sub.PeriodStart, sub.TrialEnd)
}
// PeriodEnd = TrialEnd + 1 month
expected := sub.TrialEnd.AddDate(0, 1, 0)
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("PeriodEnd mismatch: %v != %v", sub.PeriodEnd, expected)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — advancePeriod with different interval types
// ---------------------------------------------------------------------------
func TestAdvancePeriod_WeeklyDefault(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_w", "Weekly", 100, "weekly", 1, 0)
StartSubscription(sub, p)
// "weekly" hits default case -> treated as monthly
expected := sub.PeriodStart.AddDate(0, 1, 0)
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("weekly defaults to monthly: got %v, want %v", sub.PeriodEnd, expected)
}
}
func TestAdvancePeriod_DailyDefault(t *testing.T) {
sub := &subscription.Subscription{}
p := makePlan("plan_d", "Daily", 100, "daily", 5, 0)
StartSubscription(sub, p)
// "daily" hits default case -> treated as monthly with count=5
expected := sub.PeriodStart.AddDate(0, 5, 0)
if !sub.PeriodEnd.Equal(expected) {
t.Fatalf("daily defaults to monthly: got %v, want %v", sub.PeriodEnd, expected)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — ChangePlan: verify plan is always updated
// ---------------------------------------------------------------------------
func TestChangePlan_AlwaysUpdatesPlanEvenWithProration(t *testing.T) {
now := time.Now()
old := makePlan("plan_old2", "Old2", 1000, types.Monthly, 1, 0)
new_ := makePlan("plan_new2", "New2", 5000, types.Yearly, 1, 0)
sub := &subscription.Subscription{
Plan: *old,
PlanId: "plan_old2",
PeriodStart: now.AddDate(0, 0, -10),
PeriodEnd: now.AddDate(0, 0, 20),
}
_, err := ChangePlan(sub, new_, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.PlanId != "plan_new2" {
t.Fatalf("PlanId should be updated: %s", sub.PlanId)
}
if sub.Plan.Name != "New2" {
t.Fatalf("Plan.Name should be updated: %s", sub.Plan.Name)
}
if sub.Plan.Price != 5000 {
t.Fatalf("Plan.Price should be updated: %d", sub.Plan.Price)
}
if sub.Plan.Interval != types.Yearly {
t.Fatalf("Plan.Interval should be updated: %s", sub.Plan.Interval)
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — CancelSubscription: verify Canceled flag
// ---------------------------------------------------------------------------
func TestCancelSubscription_ImmediateSetsAllFlags(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Active}
before := time.Now()
err := CancelSubscription(sub, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
after := time.Now()
if sub.Status != subscription.Canceled {
t.Fatalf("Status = %s, want Canceled", sub.Status)
}
if !sub.Canceled {
t.Fatal("Canceled flag should be true")
}
if sub.CanceledAt.Before(before) || sub.CanceledAt.After(after) {
t.Fatalf("CanceledAt out of range: %v", sub.CanceledAt)
}
if sub.Ended.Before(before) || sub.Ended.After(after) {
t.Fatalf("Ended out of range: %v", sub.Ended)
}
if sub.EndCancel {
t.Fatal("EndCancel should be false for immediate cancel")
}
}
func TestCancelSubscription_AtPeriodEndDoesNotSetEnded(t *testing.T) {
sub := &subscription.Subscription{Status: subscription.Active}
err := CancelSubscription(sub, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.EndCancel != true {
t.Fatal("EndCancel should be true")
}
if sub.Ended.IsZero() == false {
// Ended should NOT be set for at-period-end cancel
t.Fatal("Ended should be zero for at-period-end cancel")
}
if sub.Status == subscription.Canceled {
t.Fatal("Status should NOT be Canceled for at-period-end cancel")
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — ReactivateSubscription: edge cases
// ---------------------------------------------------------------------------
func TestReactivateSubscription_FullyEndedError(t *testing.T) {
sub := &subscription.Subscription{
Status: subscription.Canceled,
Ended: time.Now(),
}
err := ReactivateSubscription(sub)
if err == nil {
t.Fatal("expected error for fully ended subscription")
}
if !strings.Contains(err.Error(), "fully ended") {
t.Fatalf("unexpected error: %s", err)
}
}
func TestReactivateSubscription_ClearsAllCancelFields(t *testing.T) {
sub := &subscription.Subscription{
Status: subscription.Active,
EndCancel: true,
Canceled: true,
CanceledAt: time.Now(),
}
err := ReactivateSubscription(sub)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub.EndCancel {
t.Fatal("EndCancel should be cleared")
}
if sub.Canceled {
t.Fatal("Canceled should be cleared")
}
if !sub.CanceledAt.IsZero() {
t.Fatal("CanceledAt should be zeroed")
}
}
// ---------------------------------------------------------------------------
// lifecycle.go — TransitionTrialToActive
// ---------------------------------------------------------------------------
func TestTransitionTrialToActive_AllNonTrialingStatuses(t *testing.T) {
statuses := []subscription.Status{
subscription.Active,
subscription.PastDue,
subscription.Canceled,
subscription.Unpaid,
}
for _, status := range statuses {
sub := &subscription.Subscription{Status: status}
err := TransitionTrialToActive(sub)
if err == nil {
t.Fatalf("expected error for status=%s", status)
}
if !strings.Contains(err.Error(), "not trialing") {
t.Fatalf("unexpected error for status=%s: %s", status, err)
}
}
}
// ---------------------------------------------------------------------------
// metering.go — CreateWatermark requires datastore (skip but confirm API)
// ---------------------------------------------------------------------------
func TestCreateWatermark_ParamsStruct(t *testing.T) {
// Just verify the function signature works
now := time.Now()
_ = now
t.Skip("requires datastore: usagewatermark.New(db) needs live db")
}