Files
zooqueenandHanzo Dev a265087b13 No route makes or records a refund; a refund or dispute is recorded only from the processor's signed webhook, once, and an inquiry that closes without a chargeback restores its period
Commerce never initiates a refund. POST /v1/billing/refunds, POST /v1/billing/refund,
POST /v1/order/:orderid/refund and POST /v1/payment/:paymentid/refund are gone with
their handlers (engine.CreateRefund, checkout Refund, square.Refund) and the helpers
only they used; GET /v1/billing/refunds stays read-only. A claim is settled by a
replacement order, never a refund.

A refund or dispute of a plan's payment reaches its invoice only from the
processor's webhook, under the processor's own refund id, through one locked
writer that reads the invoice afresh (engine.RecordRefund, engine.RecordDispute).
Every state a dispute reports reaches the invoice; INQUIRY_CLOSED holds the
payment no longer, WON stays final and LOST stays disputed.

No backfill of the payment-to-invoice index is run: the only plan payments made
before v1.50.139 are the team's own card purchases (z24, zk) and one Square
record.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-01 17:04:54 -07:00

2530 lines
75 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 — 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 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 — 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 — 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")
}