mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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]>
2530 lines
75 KiB
Go
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")
|
|
}
|