From 03ebd0b9eb32c5698263cef2a4ff14e294ecc98a Mon Sep 17 00:00:00 2001 From: Nithin Chandran Rajashankar Date: Mon, 29 Jun 2026 17:44:09 +0000 Subject: [PATCH 1/2] feat(sfn-invoicing-bedrock-chargeback-cdk): Add multi-BU invoice reconciliation pattern Step Functions Map state iterates AWS Invoice Units in parallel, fetches per-unit invoices via Invoicing APIs, runs Amazon Bedrock chargeback analysis, and writes allocations to Amazon DynamoDB ledger. --- .../README.md | 114 +++++++++++++++++ .../cdk/.gitignore | 6 + .../cdk/bin/app.ts | 15 +++ .../cdk/cdk.json | 6 + .../sfn-invoicing-bedrock-chargeback-stack.ts | 121 ++++++++++++++++++ .../cdk/package.json | 21 +++ .../cdk/tsconfig.json | 17 +++ .../example-pattern.json | 69 ++++++++++ .../src/analysis.py | 70 ++++++++++ .../src/fetch_invoices.py | 53 ++++++++ .../src/list_units.py | 28 ++++ 11 files changed, 520 insertions(+) create mode 100644 sfn-invoicing-bedrock-chargeback-cdk/README.md create mode 100644 sfn-invoicing-bedrock-chargeback-cdk/cdk/.gitignore create mode 100644 sfn-invoicing-bedrock-chargeback-cdk/cdk/bin/app.ts create mode 100644 sfn-invoicing-bedrock-chargeback-cdk/cdk/cdk.json create mode 100644 sfn-invoicing-bedrock-chargeback-cdk/cdk/lib/sfn-invoicing-bedrock-chargeback-stack.ts create mode 100644 sfn-invoicing-bedrock-chargeback-cdk/cdk/package.json create mode 100644 sfn-invoicing-bedrock-chargeback-cdk/cdk/tsconfig.json create mode 100644 sfn-invoicing-bedrock-chargeback-cdk/example-pattern.json create mode 100644 sfn-invoicing-bedrock-chargeback-cdk/src/analysis.py create mode 100644 sfn-invoicing-bedrock-chargeback-cdk/src/fetch_invoices.py create mode 100644 sfn-invoicing-bedrock-chargeback-cdk/src/list_units.py diff --git a/sfn-invoicing-bedrock-chargeback-cdk/README.md b/sfn-invoicing-bedrock-chargeback-cdk/README.md new file mode 100644 index 0000000000..4fc1b287c1 --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/README.md @@ -0,0 +1,114 @@ +# Multi-BU Invoice Reconciliation with AWS Step Functions, AWS Invoicing APIs, Amazon Bedrock, and Amazon DynamoDB + +This pattern deploys an AWS Step Functions state machine that uses a Map state to iterate over AWS Invoice Units (business units), fetches invoice summaries for each unit via AWS Invoicing APIs, runs Amazon Bedrock analysis for chargeback allocation, and stores results in an Amazon DynamoDB ledger. + +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/sfn-invoicing-bedrock-chargeback-cdk + +Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Requirements + +* [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [Node.js 20+](https://nodejs.org/en/download/) installed +* [AWS CDK v2](https://docs.aws.amazon.com/cdk/v2/guide/getting-started.html) installed and bootstrapped (`cdk bootstrap`) +* [Amazon Bedrock model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) enabled for Anthropic Claude Sonnet in your target region +* At least one [AWS Invoice Unit](https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/manage-invoice-units.html) configured in your account + +## Architecture + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ AWS Step Functions │ +│ │ +│ ┌─────────────────┐ ┌──────────────────────────────────────────────┐ │ +│ │ ListUnitsTask │───▶│ Map State (parallel per Invoice Unit) │ │ +│ │ (AWS Lambda) │ │ │ │ +│ │ Invoicing API: │ │ ┌─────────────────┐ ┌──────────────────┐ │ │ +│ │ ListInvoiceUnits│ │ │ FetchInvoicesTask│─▶│ AnalysisTask │ │ │ +│ └─────────────────┘ │ │ (AWS Lambda) │ │ (AWS Lambda) │ │ │ +│ │ │ Invoicing API: │ │ Bedrock Converse │ │ │ +│ │ │ ListInvoice │ │ + DynamoDB Write │ │ │ +│ │ │ Summaries │ │ │ │ │ +│ │ └─────────────────┘ └──────────────────┘ │ │ +│ └──────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ Amazon DynamoDB │ + │ Chargeback Ledger │ + └──────────────────┘ +``` + +## How it works + +1. **ListUnitsTask** — An AWS Lambda function calls the AWS Invoicing `ListInvoiceUnits` API with pagination to discover all business units in the account. + +2. **Map State** — AWS Step Functions iterates over each Invoice Unit in parallel (max concurrency 5), processing them independently. + +3. **FetchInvoicesTask** — For each unit, an AWS Lambda function calls `ListInvoiceSummaries` filtered by that unit, retrieving the last 90 days of invoice data. + +4. **AnalysisTask** — An AWS Lambda function sends the invoice data to Amazon Bedrock (Claude Sonnet) for FinOps analysis: total spend calculation, month-over-month trend detection, chargeback allocation recommendations, and cost optimization opportunities. Results are written to Amazon DynamoDB. + +5. **Amazon DynamoDB Ledger** — Stores chargeback allocations per business unit per period, creating an auditable financial record. + +## Deployment Instructions + +1. Clone the repository: + ```bash + git clone https://github.com/aws-samples/serverless-patterns + cd serverless-patterns/sfn-invoicing-bedrock-chargeback-cdk/cdk + ``` + +2. Install dependencies: + ```bash + npm install + ``` + +3. Deploy the stack: + ```bash + cdk deploy + ``` + +4. (Optional) Use a different model: + ```bash + cdk deploy --context modelId=us.anthropic.claude-sonnet-4-20250514-v1:0 + ``` + +## Testing + +1. Start a Step Functions execution: + ```bash + aws stepfunctions start-execution \ + --state-machine-arn \ + --input '{}' + ``` + +2. Monitor the execution: + ```bash + aws stepfunctions describe-execution \ + --execution-arn + ``` + +3. Once complete (status: SUCCEEDED), check the Amazon DynamoDB chargeback ledger: + ```bash + aws dynamodb scan \ + --table-name + ``` + +4. Expected result: One item per Invoice Unit with fields including `unitName`, `invoiceCount`, `analysis` (JSON with totalSpend, trend, chargebackAllocation, optimizations), and `analyzedAt` timestamp. + +## Cleanup + +```bash +cdk destroy +``` + +> **Warning:** This deletes the Amazon DynamoDB table and all chargeback history. The table uses `RemovalPolicy.DESTROY` for cleanup convenience. In production, use `RETAIN` or enable point-in-time recovery. + +---- +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/sfn-invoicing-bedrock-chargeback-cdk/cdk/.gitignore b/sfn-invoicing-bedrock-chargeback-cdk/cdk/.gitignore new file mode 100644 index 0000000000..33526d806d --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/cdk/.gitignore @@ -0,0 +1,6 @@ +node_modules +build +cdk.out +cdk.context.json +*.js +*.d.ts diff --git a/sfn-invoicing-bedrock-chargeback-cdk/cdk/bin/app.ts b/sfn-invoicing-bedrock-chargeback-cdk/cdk/bin/app.ts new file mode 100644 index 0000000000..a30a0704a5 --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/cdk/bin/app.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 + +import 'source-map-support/register'; +import * as cdk from 'aws-cdk-lib'; +import { SfnInvoicingBedrockChargebackStack } from '../lib/sfn-invoicing-bedrock-chargeback-stack'; + +const app = new cdk.App(); +new SfnInvoicingBedrockChargebackStack(app, 'SfnInvoicingBedrockChargebackStack', { + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION || 'us-east-1', + }, +}); diff --git a/sfn-invoicing-bedrock-chargeback-cdk/cdk/cdk.json b/sfn-invoicing-bedrock-chargeback-cdk/cdk/cdk.json new file mode 100644 index 0000000000..71e44abfe2 --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/cdk/cdk.json @@ -0,0 +1,6 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/app.ts", + "context": { + "modelId": "us.anthropic.claude-sonnet-4-20250514-v1:0" + } +} diff --git a/sfn-invoicing-bedrock-chargeback-cdk/cdk/lib/sfn-invoicing-bedrock-chargeback-stack.ts b/sfn-invoicing-bedrock-chargeback-cdk/cdk/lib/sfn-invoicing-bedrock-chargeback-stack.ts new file mode 100644 index 0000000000..5784a9a5cd --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/cdk/lib/sfn-invoicing-bedrock-chargeback-stack.ts @@ -0,0 +1,121 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 + +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; +import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as path from 'path'; + +export class SfnInvoicingBedrockChargebackStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const modelId = this.node.tryGetContext('modelId') || + 'us.anthropic.claude-sonnet-4-20250514-v1:0'; + + // DynamoDB table for chargeback ledger + const chargebackTable = new dynamodb.Table(this, 'ChargebackLedger', { + partitionKey: { name: 'invoiceUnitId', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'period', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // Lambda: List Invoice Units + const listUnitsFn = new lambda.Function(this, 'ListInvoiceUnits', { + runtime: lambda.Runtime.PYTHON_3_12, + handler: 'list_units.handler', + code: lambda.Code.fromAsset(path.join(__dirname, '..', '..', 'src')), + timeout: cdk.Duration.seconds(30), + memorySize: 256, + }); + + listUnitsFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['invoicing:ListInvoiceUnits'], + resources: ['*'], // Invoicing API does not support resource-level permissions + })); + + // Lambda: Fetch Invoices per Unit + const fetchInvoicesFn = new lambda.Function(this, 'FetchInvoices', { + runtime: lambda.Runtime.PYTHON_3_12, + handler: 'fetch_invoices.handler', + code: lambda.Code.fromAsset(path.join(__dirname, '..', '..', 'src')), + timeout: cdk.Duration.seconds(60), + memorySize: 256, + }); + + fetchInvoicesFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['invoicing:ListInvoiceSummaries'], + resources: ['*'], // Invoicing API does not support resource-level permissions + })); + + // Lambda: Bedrock Analysis + DynamoDB write + const analysisFn = new lambda.Function(this, 'BedrockAnalysis', { + runtime: lambda.Runtime.PYTHON_3_12, + handler: 'analysis.handler', + code: lambda.Code.fromAsset(path.join(__dirname, '..', '..', 'src')), + timeout: cdk.Duration.seconds(120), + memorySize: 512, + environment: { + MODEL_ID: modelId, + TABLE_NAME: chargebackTable.tableName, + }, + }); + + analysisFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['bedrock:InvokeModel'], + resources: [ + `arn:aws:bedrock:${this.region}:${this.account}:inference-profile/${modelId}`, + 'arn:aws:bedrock:*::foundation-model/*', + ], + })); + + chargebackTable.grantWriteData(analysisFn); + + // Step Functions definition + const listUnitsTask = new tasks.LambdaInvoke(this, 'ListUnitsTask', { + lambdaFunction: listUnitsFn, + outputPath: '$.Payload', + }); + + const fetchInvoicesTask = new tasks.LambdaInvoke(this, 'FetchInvoicesTask', { + lambdaFunction: fetchInvoicesFn, + outputPath: '$.Payload', + }); + + const analysisTask = new tasks.LambdaInvoke(this, 'AnalysisTask', { + lambdaFunction: analysisFn, + outputPath: '$.Payload', + }); + + // Map state: iterate over each invoice unit in parallel + const mapState = new sfn.Map(this, 'ProcessEachUnit', { + itemsPath: '$.units', + maxConcurrency: 5, + }); + + mapState.itemProcessor( + fetchInvoicesTask.next(analysisTask) + ); + + const definition = listUnitsTask.next(mapState); + + const stateMachine = new sfn.StateMachine(this, 'InvoiceReconciliation', { + definitionBody: sfn.DefinitionBody.fromChainable(definition), + timeout: cdk.Duration.minutes(15), + }); + + // Outputs + new cdk.CfnOutput(this, 'StateMachineArn', { + value: stateMachine.stateMachineArn, + }); + + new cdk.CfnOutput(this, 'ChargebackTableName', { + value: chargebackTable.tableName, + }); + } +} diff --git a/sfn-invoicing-bedrock-chargeback-cdk/cdk/package.json b/sfn-invoicing-bedrock-chargeback-cdk/cdk/package.json new file mode 100644 index 0000000000..5fab0e02ea --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/cdk/package.json @@ -0,0 +1,21 @@ +{ + "name": "sfn-invoicing-bedrock-chargeback-cdk", + "version": "1.0.0", + "bin": { + "app": "bin/app.ts" + }, + "scripts": { + "build": "tsc", + "synth": "cdk synth" + }, + "dependencies": { + "aws-cdk-lib": "^2.160.0", + "constructs": "^10.0.0", + "source-map-support": "^0.5.21" + }, + "devDependencies": { + "@types/node": "^20.17.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.5" + } +} diff --git a/sfn-invoicing-bedrock-chargeback-cdk/cdk/tsconfig.json b/sfn-invoicing-bedrock-chargeback-cdk/cdk/tsconfig.json new file mode 100644 index 0000000000..4d6d08bb1d --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/cdk/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["es2020"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "esModuleInterop": true, + "outDir": "./build", + "rootDir": "." + }, + "exclude": ["node_modules", "build"] +} diff --git a/sfn-invoicing-bedrock-chargeback-cdk/example-pattern.json b/sfn-invoicing-bedrock-chargeback-cdk/example-pattern.json new file mode 100644 index 0000000000..da339ffe64 --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/example-pattern.json @@ -0,0 +1,69 @@ +{ + "title": "Multi-BU Invoice Reconciliation with AWS Step Functions, Amazon Bedrock, and Amazon DynamoDB", + "description": "Deploy an AWS Step Functions Map state that iterates Invoice Units, fetches per-BU invoices, runs Amazon Bedrock chargeback analysis, and writes to Amazon DynamoDB.", + "language": "Python", + "level": "400", + "framework": "AWS CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern deploys an AWS Step Functions state machine with a Map state that processes each AWS Invoice Unit in parallel. For each business unit, it fetches invoice summaries via the AWS Invoicing APIs, sends the data to Amazon Bedrock for FinOps analysis (spend trends, chargeback allocation, optimization recommendations), and writes the results to an Amazon DynamoDB ledger.", + "The composition is non-reducible: AWS Step Functions orchestrates parallel processing across units, AWS Invoicing APIs provide the financial data, Amazon Bedrock generates intelligent chargeback allocations that no rule-based system can replicate, and Amazon DynamoDB provides the durable audit trail. Removing any component breaks the architecture." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/sfn-invoicing-bedrock-chargeback-cdk", + "templateURL": "serverless-patterns/sfn-invoicing-bedrock-chargeback-cdk", + "projectFolder": "sfn-invoicing-bedrock-chargeback-cdk", + "templateFile": "cdk/lib/sfn-invoicing-bedrock-chargeback-stack.ts" + } + }, + "resources": { + "bullets": [ + { + "text": "AWS Invoicing APIs", + "link": "https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/manage-invoice-units.html" + }, + { + "text": "AWS Step Functions Map State", + "link": "https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-map-state.html" + }, + { + "text": "Amazon Bedrock Converse API", + "link": "https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-call.html" + }, + { + "text": "Amazon DynamoDB", + "link": "https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Introduction.html" + } + ] + }, + "deploy": { + "text": [ + "cd sfn-invoicing-bedrock-chargeback-cdk/cdk", + "npm install", + "cdk deploy" + ] + }, + "testing": { + "text": [ + "Start a Step Functions execution: aws stepfunctions start-execution --state-machine-arn <StateMachineArn> --input '{}'", + "Monitor: aws stepfunctions describe-execution --execution-arn <executionArn>", + "Check results: aws dynamodb scan --table-name <ChargebackTableName>" + ] + }, + "cleanup": { + "text": [ + "Delete the stack: cdk destroy.", + "Warning: This deletes the Amazon DynamoDB chargeback ledger and all history." + ] + }, + "authors": [ + { + "name": "Nithin Chandran R", + "bio": "Technical Account Manager at AWS", + "linkedin": "nithin-chandran-r" + } + ] +} diff --git a/sfn-invoicing-bedrock-chargeback-cdk/src/analysis.py b/sfn-invoicing-bedrock-chargeback-cdk/src/analysis.py new file mode 100644 index 0000000000..742b5f3cb3 --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/src/analysis.py @@ -0,0 +1,70 @@ +# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +"""Runs Amazon Bedrock analysis on invoice data and writes chargeback allocation to DynamoDB.""" + +import json +import os +import boto3 +from datetime import datetime + + +def handler(event, context): + """Analyze invoices for one BU via Bedrock and write chargeback to DynamoDB.""" + bedrock = boto3.client('bedrock-runtime') + dynamodb = boto3.resource('dynamodb') + + model_id = os.environ['MODEL_ID'] + table_name = os.environ['TABLE_NAME'] + table = dynamodb.Table(table_name) + + invoice_unit_id = event.get('invoiceUnitId', '') + unit_name = event.get('unitName', 'Unknown') + invoices = event.get('invoices', []) + period_start = event.get('periodStart', '') + period_end = event.get('periodEnd', '') + + # Build prompt for Bedrock + invoice_summary = json.dumps(invoices, indent=2) + prompt = ( + f"You are a FinOps analyst. Analyze the following AWS invoice data for business unit '{unit_name}' " + f"(period: {period_start} to {period_end}).\n\n" + f"Invoice data:\n{invoice_summary}\n\n" + "Provide:\n" + "1. Total spend for this business unit\n" + "2. Month-over-month trend (increasing/decreasing/stable)\n" + "3. Chargeback allocation recommendation (percentage split if shared services detected)\n" + "4. Cost optimization opportunities\n\n" + "Respond in JSON format with keys: totalSpend, trend, chargebackAllocation, optimizations" + ) + + try: + response = bedrock.converse( + modelId=model_id, + messages=[{'role': 'user', 'content': [{'text': prompt}]}], + inferenceConfig={'maxTokens': 1024, 'temperature': 0.1}, + ) + + analysis_text = response['output']['message']['content'][0]['text'] + + # Write to DynamoDB + table.put_item(Item={ + 'invoiceUnitId': invoice_unit_id, + 'period': f"{period_start}_{period_end}", + 'unitName': unit_name, + 'invoiceCount': len(invoices), + 'analysis': analysis_text, + 'analyzedAt': datetime.utcnow().isoformat(), + 'modelId': model_id, + }) + + return { + 'invoiceUnitId': invoice_unit_id, + 'unitName': unit_name, + 'invoiceCount': len(invoices), + 'analysis': analysis_text, + 'status': 'SUCCESS', + } + except Exception as e: + print(f'Error analyzing invoices for unit {invoice_unit_id}: {e}') + raise diff --git a/sfn-invoicing-bedrock-chargeback-cdk/src/fetch_invoices.py b/sfn-invoicing-bedrock-chargeback-cdk/src/fetch_invoices.py new file mode 100644 index 0000000000..a4e3db9752 --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/src/fetch_invoices.py @@ -0,0 +1,53 @@ +# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +"""Fetches invoice summaries for a specific Invoice Unit.""" + +import json +import boto3 +from datetime import datetime, timedelta + + +def handler(event, context): + """Fetch invoice summaries for one invoice unit (called per-unit by Map state).""" + client = boto3.client('invoicing') + + invoice_unit_id = event.get('invoiceUnitId', '') + unit_name = event.get('name', 'Unknown') + + # Fetch invoices from the last 90 days + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=90) + + try: + response = client.list_invoice_summaries( + Filter={ + 'InvoiceUnitId': invoice_unit_id, + 'TimeRange': { + 'StartDate': start_date.strftime('%Y-%m-%d'), + 'EndDate': end_date.strftime('%Y-%m-%d'), + }, + } + ) + + invoices = [] + for summary in response.get('InvoiceSummaries', []): + invoices.append({ + 'invoiceId': summary.get('InvoiceId', ''), + 'issuedDate': summary.get('IssuedDate', ''), + 'totalAmount': str(summary.get('TotalAmount', {}).get('Amount', '0')), + 'currency': summary.get('TotalAmount', {}).get('Currency', 'USD'), + 'status': summary.get('Status', ''), + }) + + return { + 'invoiceUnitId': invoice_unit_id, + 'unitName': unit_name, + 'invoices': invoices, + 'totalInvoices': len(invoices), + 'periodStart': start_date.strftime('%Y-%m-%d'), + 'periodEnd': end_date.strftime('%Y-%m-%d'), + } + except Exception as e: + print(f'Error fetching invoices for unit {invoice_unit_id}: {e}') + raise diff --git a/sfn-invoicing-bedrock-chargeback-cdk/src/list_units.py b/sfn-invoicing-bedrock-chargeback-cdk/src/list_units.py new file mode 100644 index 0000000000..7b7c33a4ea --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/src/list_units.py @@ -0,0 +1,28 @@ +# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +"""Lists all AWS Invoice Units in the account.""" + +import json +import boto3 + + +def handler(event, context): + """Return all invoice units for the Step Functions Map state to iterate.""" + client = boto3.client('invoicing') + + try: + units = [] + paginator = client.get_paginator('list_invoice_units') + for page in paginator.paginate(): + for unit in page.get('InvoiceUnits', []): + units.append({ + 'invoiceUnitId': unit['InvoiceUnitArn'].split('/')[-1], + 'invoiceUnitArn': unit['InvoiceUnitArn'], + 'name': unit.get('Name', 'Unknown'), + 'description': unit.get('Description', ''), + }) + return {'units': units, 'count': len(units)} + except Exception as e: + print(f'Error listing invoice units: {e}') + raise From a54e4463630c937ff21d0174cf0ab45f6de3c468 Mon Sep 17 00:00:00 2001 From: Nithin Chandran Rajashankar Date: Mon, 29 Jun 2026 18:57:41 +0000 Subject: [PATCH 2/2] fix(sfn-invoicing-bedrock-chargeback-cdk): Fix ListInvoiceSummaries API params Correct API parameters: Selector (required) with ResourceType/Value, Filter.TimeInterval (not TimeRange), 30-day max window. Add STS permission for account ID lookup. --- .../README.md | 2 +- .../sfn-invoicing-bedrock-chargeback-stack.ts | 2 +- .../cdk/package.json | 4 +-- .../src/fetch_invoices.py | 31 ++++++++++++------- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/sfn-invoicing-bedrock-chargeback-cdk/README.md b/sfn-invoicing-bedrock-chargeback-cdk/README.md index 4fc1b287c1..091a5751f5 100644 --- a/sfn-invoicing-bedrock-chargeback-cdk/README.md +++ b/sfn-invoicing-bedrock-chargeback-cdk/README.md @@ -48,7 +48,7 @@ Important: this application uses various AWS services and there are costs associ 2. **Map State** — AWS Step Functions iterates over each Invoice Unit in parallel (max concurrency 5), processing them independently. -3. **FetchInvoicesTask** — For each unit, an AWS Lambda function calls `ListInvoiceSummaries` filtered by that unit, retrieving the last 90 days of invoice data. +3. **FetchInvoicesTask** — For each unit, an AWS Lambda function calls `ListInvoiceSummaries` filtered by account, retrieving the last 30 days of invoice data. 4. **AnalysisTask** — An AWS Lambda function sends the invoice data to Amazon Bedrock (Claude Sonnet) for FinOps analysis: total spend calculation, month-over-month trend detection, chargeback allocation recommendations, and cost optimization opportunities. Results are written to Amazon DynamoDB. diff --git a/sfn-invoicing-bedrock-chargeback-cdk/cdk/lib/sfn-invoicing-bedrock-chargeback-stack.ts b/sfn-invoicing-bedrock-chargeback-cdk/cdk/lib/sfn-invoicing-bedrock-chargeback-stack.ts index 5784a9a5cd..3e6508daf0 100644 --- a/sfn-invoicing-bedrock-chargeback-cdk/cdk/lib/sfn-invoicing-bedrock-chargeback-stack.ts +++ b/sfn-invoicing-bedrock-chargeback-cdk/cdk/lib/sfn-invoicing-bedrock-chargeback-stack.ts @@ -49,7 +49,7 @@ export class SfnInvoicingBedrockChargebackStack extends cdk.Stack { }); fetchInvoicesFn.addToRolePolicy(new iam.PolicyStatement({ - actions: ['invoicing:ListInvoiceSummaries'], + actions: ['invoicing:ListInvoiceSummaries', 'sts:GetCallerIdentity'], resources: ['*'], // Invoicing API does not support resource-level permissions })); diff --git a/sfn-invoicing-bedrock-chargeback-cdk/cdk/package.json b/sfn-invoicing-bedrock-chargeback-cdk/cdk/package.json index 5fab0e02ea..1ea9559495 100644 --- a/sfn-invoicing-bedrock-chargeback-cdk/cdk/package.json +++ b/sfn-invoicing-bedrock-chargeback-cdk/cdk/package.json @@ -9,8 +9,8 @@ "synth": "cdk synth" }, "dependencies": { - "aws-cdk-lib": "^2.160.0", - "constructs": "^10.0.0", + "aws-cdk-lib": "^2.185.0", + "constructs": "^10.4.2", "source-map-support": "^0.5.21" }, "devDependencies": { diff --git a/sfn-invoicing-bedrock-chargeback-cdk/src/fetch_invoices.py b/sfn-invoicing-bedrock-chargeback-cdk/src/fetch_invoices.py index a4e3db9752..77b42a7aac 100644 --- a/sfn-invoicing-bedrock-chargeback-cdk/src/fetch_invoices.py +++ b/sfn-invoicing-bedrock-chargeback-cdk/src/fetch_invoices.py @@ -5,39 +5,48 @@ import json import boto3 +import os from datetime import datetime, timedelta def handler(event, context): """Fetch invoice summaries for one invoice unit (called per-unit by Map state).""" client = boto3.client('invoicing') + sts = boto3.client('sts') invoice_unit_id = event.get('invoiceUnitId', '') unit_name = event.get('name', 'Unknown') - # Fetch invoices from the last 90 days + # Get current account ID for selector + account_id = sts.get_caller_identity()['Account'] + + # Fetch invoices from the last 30 days (API limit: max 1 month) end_date = datetime.utcnow() - start_date = end_date - timedelta(days=90) + start_date = end_date - timedelta(days=30) try: response = client.list_invoice_summaries( + Selector={ + 'ResourceType': 'ACCOUNT_ID', + 'Value': account_id, + }, Filter={ - 'InvoiceUnitId': invoice_unit_id, - 'TimeRange': { - 'StartDate': start_date.strftime('%Y-%m-%d'), - 'EndDate': end_date.strftime('%Y-%m-%d'), + 'TimeInterval': { + 'StartDate': start_date.strftime('%Y-%m-%dT00:00:00Z'), + 'EndDate': end_date.strftime('%Y-%m-%dT00:00:00Z'), }, - } + }, ) invoices = [] for summary in response.get('InvoiceSummaries', []): invoices.append({ 'invoiceId': summary.get('InvoiceId', ''), - 'issuedDate': summary.get('IssuedDate', ''), - 'totalAmount': str(summary.get('TotalAmount', {}).get('Amount', '0')), - 'currency': summary.get('TotalAmount', {}).get('Currency', 'USD'), - 'status': summary.get('Status', ''), + 'accountId': summary.get('AccountId', ''), + 'billingPeriod': str(summary.get('BillingPeriod', {})), + 'totalAmount': str(summary.get('BaseCurrencyAmount', {}).get('Amount', '0')), + 'currency': summary.get('BaseCurrencyAmount', {}).get('Currency', 'USD'), + 'invoiceType': summary.get('InvoiceType', ''), }) return {