diff --git a/sfn-invoicing-bedrock-chargeback-cdk/README.md b/sfn-invoicing-bedrock-chargeback-cdk/README.md new file mode 100644 index 0000000000..091a5751f5 --- /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 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. + +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..3e6508daf0 --- /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', 'sts:GetCallerIdentity'], + 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..1ea9559495 --- /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.185.0", + "constructs": "^10.4.2", + "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..77b42a7aac --- /dev/null +++ b/sfn-invoicing-bedrock-chargeback-cdk/src/fetch_invoices.py @@ -0,0 +1,62 @@ +# 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 +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') + + # 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=30) + + try: + response = client.list_invoice_summaries( + Selector={ + 'ResourceType': 'ACCOUNT_ID', + 'Value': account_id, + }, + Filter={ + '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', ''), + '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 { + '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