@@ -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+ }
0 commit comments