From f35a95cf76c04a22ba46cd47cb2452d14af48a55 Mon Sep 17 00:00:00 2001 From: quantdale Date: Sun, 6 Sep 2026 21:04:25 +0800 Subject: [PATCH 1/2] feat(billing): add BulkUpdateBillingGroup RPC for bulk invoice settings update Additive contract change: - Billing.BulkUpdateBillingGroup, POST /v1/billinggroups:bulkUpdate, long-running operation (protos.Operation) - Request: csvContent, delimiter, hasHeader, vendor, dryRun - Per-row results (BulkBillingGroupInvoiceSettingsRow): canonical CSV row index, billing group ID, status, errors, disposition, csvLineNumber, normalized settings with per-field source classification - NormalizedInvoiceSettings extended additively to 22 fields (fields 1-8 retain numbers; 9-22 added) - Blank Update cells preserve existing values; missing billing group fails per row; dry-run shares normalization with execution - BulkCreateBillingGroup shapes unchanged - openapiv2/apidocs.swagger.json regenerated (additive delta only) OpenSpec: alphauslabs/ripple-openspec PR #82 (add-billing-group-bulk-invoice-settings-update) --- billing/v1/billing.proto | 159 ++++++++++++++++++++++++++++++++- openapiv2/apidocs.swagger.json | 59 ++++++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) diff --git a/billing/v1/billing.proto b/billing/v1/billing.proto index 92dbea30..3946d19d 100644 --- a/billing/v1/billing.proto +++ b/billing/v1/billing.proto @@ -1092,6 +1092,18 @@ service Billing { }; } + // Update invoice settings on existing billing groups in bulk from a CSV file. + // Unlike BulkCreateBillingGroup, a blank CSV cell preserves the billing group's existing + // value for that setting rather than clearing it, and a row referencing a billing group + // that does not exist fails only that row (no implicit create). + // Returns a long-running operation. Poll status via the Operations service (GET /ops/v1/{name}). + rpc BulkUpdateBillingGroup(BulkUpdateBillingGroupRequest) returns (protos.Operation) { + option (google.api.http) = { + post: "/v1/billinggroups:bulkUpdate" + body: "*" + }; + } + // Create Exclude Service Entry rpc CreateExcludeServiceEntry(CreateExcludeServiceEntryRequest) returns (CreateExcludeServiceEntryResponse) { option (google.api.http) = { @@ -3943,6 +3955,151 @@ message BulkCreateBillingGroupMetadata { // Job end timestamp. Empty if still running. string endTime = 8; + + // Per-row normalized Invoice Settings results, populated for dryRun requests and for + // completed executions. Reused by BulkUpdateBillingGroupMetadata so Dry-run and execution + // for both Create and Update share one row-result shape. + repeated BulkBillingGroupInvoiceSettingsRow rows = 9; +} + +// A single Invoice Settings field's normalized value together with where that value came from. +message NormalizedInvoiceSettingField { + // Canonical string-encoded normalized value. Empty if the setting has no value. + string value = 1; + + // Where this value came from. One of: "uploaded", "existing", "default_derived", + // "org_derived", "connection_resolved". "existing" means the value was carried + // forward unchanged from the billing group's current stored state (the common case + // for a blank Update CSV cell — see BulkUpdateBillingGroup's merge semantics). + // Omitted (empty) if the server cannot determine a classification. + // A field the Bulk CSV schema does not accept as an input column (e.g. support_amount_target) + // must never be classified as "uploaded". + string source = 2; +} + +// Server-normalized Invoice Settings for a single bulk row, as they will be (Dry-run) or were +// (execution) applied. Shared by BulkCreateBillingGroup and BulkUpdateBillingGroup results. +message NormalizedInvoiceSettings { + NormalizedInvoiceSettingField calcType = 1; + NormalizedInvoiceSettingField displayCost = 2; + NormalizedInvoiceSettingField supportAmountTarget = 3; + NormalizedInvoiceSettingField supportFeeCalcTarget = 4; + NormalizedInvoiceSettingField substitutionFeeCalcTarget = 5; + NormalizedInvoiceSettingField invoiceTemplate = 6; + NormalizedInvoiceSettingField customService = 7; + NormalizedInvoiceSettingField serviceDiscounts = 8; + // Complete per-vendor normalized invoice settings. These fields are additive; + // fields 1-8 above retain their original numbers. + NormalizedInvoiceSettingField currency = 9; + NormalizedInvoiceSettingField taxRate = 10; + NormalizedInvoiceSettingField discountRate = 11; + NormalizedInvoiceSettingField discountTargetUsage = 12; + NormalizedInvoiceSettingField discountCalcLogic = 13; + NormalizedInvoiceSettingField substitutionFee = 14; + NormalizedInvoiceSettingField substitutionRate = 15; + NormalizedInvoiceSettingField substitutionFix = 16; + NormalizedInvoiceSettingField substitutionFeeTargetUsage = 17; + NormalizedInvoiceSettingField substitutionFeeCalcType = 18; + NormalizedInvoiceSettingField supportFee = 19; + NormalizedInvoiceSettingField supportRate = 20; + NormalizedInvoiceSettingField supportFix = 21; + NormalizedInvoiceSettingField supportFeeAdjustment = 22; +} + +// Per-row result for a bulk Create or Update Invoice Settings operation. +message BulkBillingGroupInvoiceSettingsRow { + // The canonical logical data-row index in the original CSV (zero-based, + // excluding the header when hasHeader is true). This index is preserved for + // valid rows, parse errors, and worker results; it is never compressed after + // a malformed or missing row. + int32 index = 1; + + // The billing group ID. For Update, the resolved existing billing group. For Create, + // populated once known (empty for a row that has not yet been processed on a dryRun). + string billingGroupId = 2; + + // Row status: "valid", "invalid", "not_found", "created", "updated", or "failed". + string status = 3; + + // Human-readable error message(s) for this row, if any. + repeated string errors = 4; + + // The normalized Invoice Settings this row will produce (dryRun) or produced (execution). + NormalizedInvoiceSettings normalizedSettings = 5; + + // Explicit row disposition. Supported values include "ready", "invalid", + // "not_found", "updated", and "failed". It is separate from status so a + // terminal operation status cannot be mistaken for row success. + string disposition = 6; + + // One-based physical CSV line number when known, including a header line. + // Zero means the line number was not available. + int32 csvLineNumber = 7; +} + +message BulkUpdateBillingGroupRequest { + // Required. The cloud vendor for all billing groups in this CSV. + // Must be one of: aws, azure, gcp. + string vendor = 5; + + // Required. The CSV file content as bytes. + bytes csvContent = 1; + + // Optional. The delimiter used in the CSV file. Defaults to comma (,). + string delimiter = 2; + + // Optional. Whether the CSV file has a header row. Defaults to true. + bool hasHeader = 3; + + // Optional. If true, validate the CSV and resolve/merge against existing billing group + // state, returning the normalized result, without persisting any change. + bool dryRun = 4; +} + +message BulkUpdateBillingGroupError { + // The index of the row that failed (zero-based). + int32 index = 1; + + // The billing group ID from the failed row, if present in the CSV. + string billingGroupId = 2; + + // Error message. + string message = 3; + + // Failure reason code: "not_found", "validation_failed", or "connection_resolution_failed". + string reason = 4; +} + +// Metadata for the BulkUpdateBillingGroup long-running operation. +// Stored in protos.Operation.metadata. +message BulkUpdateBillingGroupMetadata { + // The current status: "pending", "processing", "completed", "partial_failed", "failed". + string status = 1; + + // Total number of rows to process. + int32 totalCount = 2; + + // Number of rows processed so far. + int32 processedCount = 3; + + // Number of rows successfully updated. + int32 successCount = 4; + + // Number of rows that failed (including not-found billing groups). + int32 failureCount = 5; + + // Details of failed rows. + repeated BulkUpdateBillingGroupError errors = 6; + + // Job start timestamp (RFC 3339). + string startTime = 7; + + // Job end timestamp. Empty if still running. + string endTime = 8; + + // Per-row normalized Invoice Settings results, populated for dryRun requests and for + // completed executions. + repeated BulkBillingGroupInvoiceSettingsRow rows = 9; } message ExcludeServiceEntry { @@ -4262,4 +4419,4 @@ message ListInvoiceLayoutConfigBillingGroupsRequest { message ListInvoiceLayoutConfigBillingGroupsResponse { // The internal ids of the billing groups attached to this layout config. repeated string billinggroup_ids = 1; -} \ No newline at end of file +} diff --git a/openapiv2/apidocs.swagger.json b/openapiv2/apidocs.swagger.json index 62a184cd..d6c50329 100644 --- a/openapiv2/apidocs.swagger.json +++ b/openapiv2/apidocs.swagger.json @@ -6080,6 +6080,39 @@ ] } }, + "/v1/billinggroups:bulkUpdate": { + "post": { + "summary": "Update invoice settings on existing billing groups in bulk from a CSV file.\nUnlike BulkCreateBillingGroup, a blank CSV cell preserves the billing group's existing\nvalue for that setting rather than clearing it, and a row referencing a billing group\nthat does not exist fails only that row (no implicit create).\nReturns a long-running operation. Poll status via the Operations service (GET /ops/v1/{name}).", + "operationId": "Billing_BulkUpdateBillingGroup", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/protosOperation" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1BulkUpdateBillingGroupRequest" + } + } + ], + "tags": [ + "Billing" + ] + } + }, "/v1/billinggroups/billingconductor/{id}": { "get": { "summary": "WORK-IN-PROGRESS: Gets all Billing Groups in AWS Billing Conductor(ABC) for specific payer id.", @@ -36420,6 +36453,32 @@ } } }, + "v1BulkUpdateBillingGroupRequest": { + "type": "object", + "properties": { + "vendor": { + "type": "string", + "description": "Required. The cloud vendor for all billing groups in this CSV.\nMust be one of: aws, azure, gcp." + }, + "csvContent": { + "type": "string", + "format": "byte", + "description": "Required. The CSV file content as bytes." + }, + "delimiter": { + "type": "string", + "description": "Optional. The delimiter used in the CSV file. Defaults to comma (,)." + }, + "hasHeader": { + "type": "boolean", + "description": "Optional. Whether the CSV file has a header row. Defaults to true." + }, + "dryRun": { + "type": "boolean", + "description": "Optional. If true, validate the CSV and resolve/merge against existing billing group\nstate, returning the normalized result, without persisting any change." + } + } + }, "v1BulkImportMonthlyMiscFeesRequest": { "type": "object", "properties": { From db01822c1c29e32041fc9c13631e8c186c3a9bc6 Mon Sep 17 00:00:00 2001 From: quantdale Date: Mon, 7 Sep 2026 14:00:59 +0800 Subject: [PATCH 2/2] docs(billing): make BulkUpdateBillingGroup contract comments match actual semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-proofing only — no wire/message/field changes: - hasHeader: document the ACTUAL semantics (true = first line names columns; false OR omitted = canonical bulk-update column order, headerless; callers SHOULD set it explicitly; value preserved on the async payload; contrast with BulkCreateBillingGroup which always treats the first line as header). The previous 'Defaults to true' comment did not match the producer. - row disposition: document the full producer vocabulary including 'connection_resolution_failed' and 'unsupported_field'. - error reason: add 'unsupported_field'; align index comment with the canonical row-index definition (zero-based, header excluded, never compressed) shared with BulkBillingGroupInvoiceSettingsRow.index. Verified: buf build OK; descriptor-level buf breaking vs origin/main clean (FILE rules, zero findings); descriptor-set diff vs main shows only 6 added messages, 1 added method, 1 additive repeated field (BulkCreateBillingGroupMetadata.rows=9); SDKs regenerated and validated from this head (Go build+test ./... green, TS tsc+pack green with 22/22 normalized fields, Python compile/import clean — with stubs for two pre-existing broken imports identical on current main). --- billing/v1/billing.proto | 34 +++++++++++++++++++++++++++------- openapiv2/apidocs.swagger.json | 2 +- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/billing/v1/billing.proto b/billing/v1/billing.proto index 3946d19d..4e127abd 100644 --- a/billing/v1/billing.proto +++ b/billing/v1/billing.proto @@ -4018,7 +4018,10 @@ message BulkBillingGroupInvoiceSettingsRow { // populated once known (empty for a row that has not yet been processed on a dryRun). string billingGroupId = 2; - // Row status: "valid", "invalid", "not_found", "created", "updated", or "failed". + // Row status: "valid" (Dry-run row ready), "invalid" (parse/validation failure), + // "not_found" (referenced billing group does not exist), "updated" (execution + // succeeded), "failed" (row-level failure during execution). "created" is reserved + // for the Create side of this shared row-result shape. string status = 3; // Human-readable error message(s) for this row, if any. @@ -4027,9 +4030,13 @@ message BulkBillingGroupInvoiceSettingsRow { // The normalized Invoice Settings this row will produce (dryRun) or produced (execution). NormalizedInvoiceSettings normalizedSettings = 5; - // Explicit row disposition. Supported values include "ready", "invalid", - // "not_found", "updated", and "failed". It is separate from status so a - // terminal operation status cannot be mistaken for row success. + // Explicit row disposition. Values: "ready" (Dry-run row will update), "invalid" + // (parse/validation failure), "not_found" (referenced billing group does not exist), + // "connection_resolution_failed" (service-discount connection resolution failed), + // "unsupported_field" (a requested setting is not supported, e.g. non-blank + // custom_service), "updated" (execution succeeded), "failed" (any other row-level + // execution failure). It is separate from status so a terminal operation status + // cannot be mistaken for row success. string disposition = 6; // One-based physical CSV line number when known, including a header line. @@ -4048,7 +4055,13 @@ message BulkUpdateBillingGroupRequest { // Optional. The delimiter used in the CSV file. Defaults to comma (,). string delimiter = 2; - // Optional. Whether the CSV file has a header row. Defaults to true. + // Optional. Whether the first CSV line names the columns. When true, the first + // line is read as a header and columns are matched by name. When false OR omitted + // (proto3 scalar default), the CSV is interpreted using the canonical bulk-update + // column order (the same order as the downloadable template, headerless). + // Callers SHOULD set this field explicitly. Unlike BulkCreateBillingGroup — which + // always interprets the first CSV line as a header — Bulk Update honors this field + // as given, and the value is preserved on the async execution payload. bool hasHeader = 3; // Optional. If true, validate the CSV and resolve/merge against existing billing group @@ -4057,7 +4070,10 @@ message BulkUpdateBillingGroupRequest { } message BulkUpdateBillingGroupError { - // The index of the row that failed (zero-based). + // The canonical logical data-row index of the failed row (zero-based, excluding + // the header line when hasHeader is true). Same definition as + // BulkBillingGroupInvoiceSettingsRow.index: preserved for parse errors and worker + // results, never compressed after a malformed or missing row. int32 index = 1; // The billing group ID from the failed row, if present in the CSV. @@ -4066,7 +4082,11 @@ message BulkUpdateBillingGroupError { // Error message. string message = 3; - // Failure reason code: "not_found", "validation_failed", or "connection_resolution_failed". + // Failure reason code: "not_found" (billing group does not exist), + // "validation_failed" (CSV parse or validation failure), + // "connection_resolution_failed" (service-discount connection resolution failed), + // or "unsupported_field" (a requested setting is not supported, e.g. non-blank + // custom_service). string reason = 4; } diff --git a/openapiv2/apidocs.swagger.json b/openapiv2/apidocs.swagger.json index d6c50329..cc4a6b00 100644 --- a/openapiv2/apidocs.swagger.json +++ b/openapiv2/apidocs.swagger.json @@ -36471,7 +36471,7 @@ }, "hasHeader": { "type": "boolean", - "description": "Optional. Whether the CSV file has a header row. Defaults to true." + "description": "Optional. Whether the first CSV line names the columns. When true, the first\nline is read as a header and columns are matched by name. When false OR omitted\n(proto3 scalar default), the CSV is interpreted using the canonical bulk-update\ncolumn order (the same order as the downloadable template, headerless).\nCallers SHOULD set this field explicitly. Unlike BulkCreateBillingGroup — which\nalways interprets the first CSV line as a header — Bulk Update honors this field\nas given, and the value is preserved on the async execution payload." }, "dryRun": { "type": "boolean",