Skip to content

Commit f0c6d91

Browse files
committed
feat: add e2e test that covers advance action
1 parent ebe3bc6 commit f0c6d91

3 files changed

Lines changed: 271 additions & 1 deletion

File tree

e2e/billinginvoices_v3_test.go

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,3 +1241,266 @@ func TestV3DeleteBillingInvoice(t *testing.T) {
12411241
assert.NotNil(t, problem)
12421242
})
12431243
}
1244+
1245+
// TestV3AdvanceBillingInvoice exercises POST /api/v3/openmeter/billing/invoices/{invoiceId}/advance
1246+
// Flow:
1247+
// - Create a customer (v3)
1248+
// - Create a standard invoice with draft status (v1)
1249+
// - Apply the advance operation via v3 POST and assert 200
1250+
// - Attempt to advance the same invoice again and assert 400
1251+
// - Attempt to advance a gathering invoice via v3 POST and assert 400
1252+
func TestV3AdvanceBillingInvoice(t *testing.T) {
1253+
c := newV3Client(t)
1254+
v1 := initClient(t)
1255+
1256+
var (
1257+
customerID string
1258+
invoiceID string // standard invoice ID
1259+
gatheringInvoiceID string // gathering invoice ID
1260+
)
1261+
1262+
t.Run("Should create a customer", func(t *testing.T) {
1263+
key := uniqueKey("advanceinv_customer")
1264+
currency := apiv3.CurrencyCode("USD")
1265+
1266+
status, customer, problem := c.CreateCustomer(apiv3.CreateCustomerRequest{
1267+
Key: key,
1268+
Name: gofakeit.ProductName(),
1269+
Currency: &currency,
1270+
})
1271+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
1272+
require.NotNil(t, customer)
1273+
customerID = customer.Id
1274+
})
1275+
1276+
t.Run("Should pin the customer to a short-draft-period billing profile", func(t *testing.T) {
1277+
require.NotEmpty(t, customerID, "depends on customer creation")
1278+
1279+
// A short, nonzero draft period parks the invoice in draft.waiting_auto_approval
1280+
// right after creation (auto-advance is otherwise instantaneous with the default
1281+
// P0D draft period, which would leave nothing in draft to advance). Once the
1282+
// period elapses, the first advance call unblocks trigger_next and drives the
1283+
// invoice forward; the second call then finds no further automatic transition.
1284+
//
1285+
// The default profile's payment settings auto-charge via CollectionMethodChargeAutomatically,
1286+
// which (with credits enabled in e2e) settles the invoice to "paid" synchronously
1287+
// the moment it's advanced past issued — leaving nothing observable to assert on.
1288+
// Overriding payment to send_invoice (same as the manual-approval profiles used
1289+
// elsewhere in this file) stops that auto-settlement, so advancing lands on a real,
1290+
// inspectable non-final status instead.
1291+
profile := createNewBillingProfileFromDefault(t, c, uniqueKey("advance_invoice"), func(profile *apiv3.CreateBillingProfileRequest) {
1292+
if profile.Workflow.Invoicing == nil {
1293+
profile.Workflow.Invoicing = &apiv3.BillingWorkflowInvoicingSettings{}
1294+
}
1295+
profile.Workflow.Invoicing.AutoAdvance = lo.ToPtr(true)
1296+
profile.Workflow.Invoicing.DraftPeriod = lo.ToPtr("PT5S")
1297+
1298+
sendInvoice := apiv3.BillingWorkflowPaymentSettings{}
1299+
require.NoError(t, sendInvoice.FromBillingWorkflowPaymentSendInvoiceSettings(apiv3.BillingWorkflowPaymentSendInvoiceSettings{
1300+
CollectionMethod: apiv3.BillingWorkflowPaymentSendInvoiceSettingsCollectionMethodSendInvoice,
1301+
}))
1302+
profile.Workflow.Payment = &sendInvoice
1303+
})
1304+
1305+
status, _, problem := c.UpdateCustomerBilling(customerID, apiv3.UpsertCustomerBillingDataRequest{
1306+
BillingProfile: &apiv3.BillingProfileReference{Id: profile.Id},
1307+
})
1308+
require.Equal(t, http.StatusOK, status, "problem: %+v", problem)
1309+
})
1310+
1311+
t.Run("Should create a single gathering invoice", func(t *testing.T) {
1312+
require.NotEmpty(t, customerID, "depends on customer creation")
1313+
1314+
now := time.Now().UTC()
1315+
price := api.RateCardUsageBasedPrice{}
1316+
require.NoError(t, price.FromFlatPriceWithPaymentTerm(api.FlatPriceWithPaymentTerm{
1317+
Amount: api.Numeric("10.00"),
1318+
Type: api.FlatPriceWithPaymentTermTypeFlat,
1319+
PaymentTerm: lo.ToPtr(api.PricePaymentTermInAdvance),
1320+
}))
1321+
1322+
invoiceAt := now.Add(time.Hour)
1323+
lineResp, err := v1.CreatePendingInvoiceLineWithResponse(t.Context(), customerID, api.InvoicePendingLineCreateInput{
1324+
Currency: "USD",
1325+
Lines: []api.InvoicePendingLineCreate{
1326+
{
1327+
Name: uniqueKey("advance_inv_gathering_line"),
1328+
InvoiceAt: invoiceAt,
1329+
Period: api.Period{
1330+
From: now.Add(-24 * time.Hour),
1331+
To: invoiceAt,
1332+
},
1333+
Price: &price,
1334+
},
1335+
},
1336+
})
1337+
require.NoError(t, err)
1338+
require.Equal(t, http.StatusCreated, lineResp.StatusCode(), "line: %s", string(lineResp.Body))
1339+
require.NotNil(t, lineResp.JSON201)
1340+
1341+
gatheringInvoiceID = (*lineResp.JSON201).Invoice.Id
1342+
require.NotEmpty(t, gatheringInvoiceID)
1343+
})
1344+
1345+
t.Run("Should create meter, feature, plan, and subscription", func(t *testing.T) {
1346+
require.NotEmpty(t, customerID, "depends on customer creation")
1347+
1348+
// An active subscription is what actually drives the billing-worker's sync
1349+
// loop for this customer; without one, a raw past-due pending line just sits
1350+
// in the gathering invoice forever (nothing else periodically collects it).
1351+
status, meter, problem := c.CreateMeter(apiv3.CreateMeterRequest{
1352+
Key: uniqueKey("advanceinv_meter"),
1353+
Name: gofakeit.ProductName(),
1354+
Aggregation: apiv3.MeterAggregationCount,
1355+
EventType: uniqueKey("advanceinv_event"),
1356+
})
1357+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
1358+
1359+
status, f, problem := c.CreateFeature(apiv3.CreateFeatureRequest{
1360+
Key: uniqueKey("advanceinv_feature"),
1361+
Name: gofakeit.ProductName(),
1362+
Meter: &apiv3.FeatureMeterReference{Id: meter.Id},
1363+
})
1364+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
1365+
1366+
status, plan, problem := c.CreatePlan(apiv3.CreatePlanRequest{
1367+
Key: uniqueKey("advanceinv_plan"),
1368+
Name: gofakeit.ProductName(),
1369+
Currency: "USD",
1370+
BillingCadence: apiv3.ISO8601Duration("P1M"),
1371+
Phases: []apiv3.BillingPlanPhase{{
1372+
Key: uniqueKey("inv_phase_1"),
1373+
Name: uniqueKey("Test Phase"),
1374+
RateCards: []apiv3.BillingRateCard{validUnitRateCard(*f)},
1375+
}},
1376+
})
1377+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
1378+
planID := plan.Id
1379+
1380+
status, plan, problem = c.PublishPlan(planID)
1381+
require.Equal(t, http.StatusOK, status, "problem: %+v", problem)
1382+
assert.Equal(t, apiv3.BillingPlanStatusActive, plan.Status)
1383+
1384+
status, _, problem = c.CreateSubscription(apiv3.BillingSubscriptionCreate{
1385+
Customer: struct {
1386+
Id *apiv3.ULID `json:"id,omitempty"`
1387+
Key *apiv3.ExternalResourceKey `json:"key,omitempty"`
1388+
}{Id: lo.ToPtr(customerID)},
1389+
Plan: struct {
1390+
Id *apiv3.ULID `json:"id,omitempty"`
1391+
Key *apiv3.ResourceKey `json:"key,omitempty"`
1392+
Version *int `json:"version,omitempty"`
1393+
}{Id: lo.ToPtr(planID)},
1394+
})
1395+
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
1396+
})
1397+
1398+
t.Run("Should create a standard invoice in draft status", func(t *testing.T) {
1399+
require.NotEmpty(t, customerID, "depends on customer creation")
1400+
1401+
now := time.Now().UTC()
1402+
price := api.RateCardUsageBasedPrice{}
1403+
require.NoError(t, price.FromFlatPriceWithPaymentTerm(api.FlatPriceWithPaymentTerm{
1404+
Amount: api.Numeric("10.00"),
1405+
Type: api.FlatPriceWithPaymentTermTypeFlat,
1406+
PaymentTerm: lo.ToPtr(api.PricePaymentTermInAdvance),
1407+
}))
1408+
1409+
lineResp, err := v1.CreatePendingInvoiceLineWithResponse(t.Context(), customerID, api.InvoicePendingLineCreateInput{
1410+
Currency: "USD",
1411+
Lines: []api.InvoicePendingLineCreate{{
1412+
Name: uniqueKey("advanceinv_line"),
1413+
InvoiceAt: now.Add(-10 * time.Hour),
1414+
Period: api.Period{
1415+
From: now.Add(-24 * time.Hour),
1416+
To: now.Add(-2 * time.Hour),
1417+
},
1418+
Price: &price,
1419+
}},
1420+
})
1421+
require.NoError(t, err)
1422+
require.Equal(t, http.StatusCreated, lineResp.StatusCode())
1423+
1424+
ctx := t.Context()
1425+
customers := api.InvoiceListParamsCustomers{customerID}
1426+
assert.EventuallyWithT(t, func(co *assert.CollectT) {
1427+
listResp, err := v1.ListInvoicesWithResponse(ctx, &api.ListInvoicesParams{
1428+
Customers: &customers,
1429+
PageSize: lo.ToPtr(api.PaginationPageSize(100)),
1430+
})
1431+
require.NoError(co, err)
1432+
require.Equal(co, http.StatusOK, listResp.StatusCode())
1433+
1434+
invoice, found := lo.Find(listResp.JSON200.Items, func(inv api.Invoice) bool {
1435+
return inv.Status == api.InvoiceStatusDraft
1436+
})
1437+
assert.True(co, found, "charges have not advanced a pending line into a standard invoice yet")
1438+
if found {
1439+
invoiceID = invoice.Id
1440+
}
1441+
}, time.Minute, time.Second)
1442+
require.NotEmpty(t, invoiceID)
1443+
})
1444+
1445+
t.Run("Should advance the standard invoice via v3 POST", func(t *testing.T) {
1446+
require.NotEmpty(t, invoiceID, "depends on invoice creation")
1447+
1448+
// The invoice rests in draft.waiting_auto_approval until the draft period
1449+
// (PT5S) elapses, so retry until the state machine's guard clears.
1450+
assert.EventuallyWithT(t, func(co *assert.CollectT) {
1451+
status, inv, problem := c.AdvanceBillingInvoice(invoiceID)
1452+
require.Equal(co, http.StatusOK, status, "problem: %+v", problem)
1453+
require.NotNil(co, inv)
1454+
1455+
stdInv, err := inv.AsBillingInvoiceStandard()
1456+
require.NoError(co, err)
1457+
assert.Equal(co, invoiceID, stdInv.Id)
1458+
// send_invoice payment settings prevent auto-settlement, so advancing
1459+
// should land on a real non-final status (e.g. issued/payment_processing),
1460+
// not race straight through to paid.
1461+
assert.NotEqual(co, apiv3.BillingInvoiceStandardStatusPaid, stdInv.Status)
1462+
}, 20*time.Second, time.Second)
1463+
})
1464+
1465+
t.Run("Should return 400 when advancing the same invoice again", func(t *testing.T) {
1466+
require.NotEmpty(t, invoiceID, "depends on invoice advance")
1467+
1468+
status, inv, problem := c.AdvanceBillingInvoice(invoiceID)
1469+
assert.Equal(t, http.StatusBadRequest, status, "problem: %+v", problem)
1470+
assert.Nil(t, inv)
1471+
assert.NotNil(t, problem)
1472+
})
1473+
1474+
t.Run("Should return 400 when advancing a gathering invoice via v3 POST", func(t *testing.T) {
1475+
require.NotEmpty(t, gatheringInvoiceID, "depends on gathering invoice creation")
1476+
1477+
status, inv, problem := c.AdvanceBillingInvoice(gatheringInvoiceID)
1478+
assert.Equal(t, http.StatusBadRequest, status, "problem: %+v", problem)
1479+
assert.Nil(t, inv)
1480+
assert.NotNil(t, problem)
1481+
})
1482+
}
1483+
1484+
// TestV3ApproveBillingInvoice exercises POST /api/v3/openmeter/billing/invoices/{invoiceId}/approve
1485+
// Flow:
1486+
// - Create a customer (v3)
1487+
// - Create a standard invoice with draft status (v1)
1488+
// - Apply the approve operation via v3 POST and assert 200
1489+
// - Attempt to approve the same invoice again and assert 400
1490+
// - Attempt to approve a gathering invoice via v3 POST and assert 400
1491+
// - Create a standard invoice with manual_approval_needed status (v1)
1492+
// - Apply the approve operation via v3 POST and assert 200
1493+
// - Attempt to approve the same invoice again and assert 400
1494+
// - Attempt to approve a gathering invoice via v3 POST and assert 400
1495+
func TestV3ApproveBillingInvoice(t *testing.T) {
1496+
}
1497+
1498+
// TestV3ApproveBillingInvoice exercises POST /api/v3/openmeter/billing/invoices/{invoiceId}/retry
1499+
// Flow:
1500+
// - Create a customer (v3)
1501+
// - Create a standard invoice with draft status (v1)
1502+
// - Apply the retry operation via v3 POST and assert 200
1503+
// - Attempt to retry the same invoice again and assert 400
1504+
// - Attempt to retry a gathering invoice via v3 POST and assert 400
1505+
func TestV3RetryBillingInvoice(t *testing.T) {
1506+
}

e2e/v3helpers_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,11 @@ func (c *v3Client) DeleteBillingInvoice(invoiceID string) (int, *v3Problem) {
437437
return status, problem
438438
}
439439

440+
func (c *v3Client) AdvanceBillingInvoice(invoiceID string) (int, *apiv3.BillingInvoice, *v3Problem) {
441+
status, raw, problem := c.do(http.MethodPost, "/billing/invoices/"+invoiceID+"/advance", nil)
442+
return decodeTyped[apiv3.BillingInvoice](c, status, raw, problem, http.StatusOK)
443+
}
444+
440445
// --- Credits ---
441446

442447
// CreateCreditGrant posts a credit grant for the given customer. customerID is

openmeter/billing/service/invoice.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,9 @@ func (s *Service) withLockedInvoiceStateMachine(
475475
}
476476

477477
if invoiceHeader.Type() != billing.InvoiceTypeStandard {
478-
return billing.StandardInvoice{}, fmt.Errorf("invoice[%s] is not a standard invoice, state machine should not be used for manipulation", in.InvoiceID.ID)
478+
return billing.StandardInvoice{}, billing.ValidationError{
479+
Err: fmt.Errorf("invoice[%s] is not a standard invoice, state machine should not be used for manipulation", in.InvoiceID.ID),
480+
}
479481
}
480482

481483
standardInvoice, err := invoiceHeader.AsStandardInvoice()

0 commit comments

Comments
 (0)