/**
* DynamoDB-backed Ledger Store
*
* Replaces the in-memory FeeRecord[] ledger in FeeCollector with
* a persistent DynamoDB table. Uses the shared document client
* from ./client.ts and the FeeRecord type from ../types.ts.
*
* Table schema (from serverless.yml):
* Hash key: transactionId (S) — maps to FeeRecord.transaction_id
* All other FeeRecord fields stored as top-level attributes.
*/
import { PutCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
import type { ScanCommandOutput } from "@aws-sdk/lib-dynamodb";
import { getDocClient } from "./client.js";
import type { FeeRecord } from "../types.js";
export class DynamoLedgerStore {
private readonly tableName: string;
constructor(tableName: string) {
this.tableName = tableName;
}
/**
* Persist a FeeRecord to DynamoDB.
*
* The record is stored with `transactionId` as the hash key
* (mapped from `transaction_id`) and every other FeeRecord field
* written as a top-level attribute.
*/
async put(record: FeeRecord): Promise<void> {
const client = getDocClient();
await client.send(
new PutCommand({
TableName: this.tableName,
Item: {
transactionId: record.transaction_id,
transaction_id: record.transaction_id,
checkout_id: record.checkout_id,
order_id: record.order_id,
gross_amount: record.gross_amount,
fee_rate: record.fee_rate,
fee_amount: record.fee_amount,
currency: record.currency,
wallet_address: record.wallet_address,
status: record.status,
created_at: record.created_at,
},
}),
);
}
/**
* Query all fee records, optionally filtered by a date range on `created_at`.
*
* When both `startDate` and `endDate` are provided the scan uses a
* FilterExpression with `created_at BETWEEN :start AND :end`.
* If only one bound is provided it is still honoured via >= / <=.
* When neither is provided every record in the table is returned.
*
* Pagination is handled automatically — if DynamoDB returns a
* `LastEvaluatedKey` the scan continues until all matching items
* have been collected.
*/
async query(startDate?: string, endDate?: string): Promise<FeeRecord[]> {
const client = getDocClient();
const items: FeeRecord[] = [];
let filterExpression: string | undefined;
let expressionAttributeValues: Record<string, unknown> | undefined;
if (startDate && endDate) {
filterExpression = "created_at BETWEEN :start AND :end";
expressionAttributeValues = {
":start": startDate,
":end": endDate,
};
} else if (startDate) {
filterExpression = "created_at >= :start";
expressionAttributeValues = { ":start": startDate };
} else if (endDate) {
filterExpression = "created_at <= :end";
expressionAttributeValues = { ":end": endDate };
}
let exclusiveStartKey: Record<string, unknown> | undefined;
do {
const params: ConstructorParameters<typeof ScanCommand>[0] = {
TableName: this.tableName,
...(filterExpression && { FilterExpression: filterExpression }),
...(expressionAttributeValues && {
ExpressionAttributeValues: expressionAttributeValues,
}),
...(exclusiveStartKey && { ExclusiveStartKey: exclusiveStartKey }),
};
const result: ScanCommandOutput = await client.send(
new ScanCommand(params),
);
if (result.Items) {
for (const item of result.Items) {
items.push(itemToFeeRecord(item));
}
}
exclusiveStartKey = result.LastEvaluatedKey as
| Record<string, unknown>
| undefined;
} while (exclusiveStartKey);
return items;
}
/**
* Sum the `fee_amount` of every record whose status is `"collected"`,
* optionally filtered by currency.
*
* Uses a FilterExpression so DynamoDB only returns matching items.
* `status` is a DynamoDB reserved word, so an ExpressionAttributeNames
* alias (`#status`) is used.
*
* Pagination is handled — scans continue until `LastEvaluatedKey` is
* exhausted.
*/
async totalCollected(currency?: string): Promise<number> {
const client = getDocClient();
let total = 0;
let filterExpression = "#status = :collected";
const expressionAttributeNames: Record<string, string> = {
"#status": "status",
};
const expressionAttributeValues: Record<string, unknown> = {
":collected": "collected",
};
if (currency) {
filterExpression += " AND currency = :currency";
expressionAttributeValues[":currency"] = currency.toUpperCase();
}
let exclusiveStartKey: Record<string, unknown> | undefined;
do {
const params: ConstructorParameters<typeof ScanCommand>[0] = {
TableName: this.tableName,
FilterExpression: filterExpression,
ExpressionAttributeNames: expressionAttributeNames,
ExpressionAttributeValues: expressionAttributeValues,
...(exclusiveStartKey && { ExclusiveStartKey: exclusiveStartKey }),
};
const result: ScanCommandOutput = await client.send(
new ScanCommand(params),
);
if (result.Items) {
for (const item of result.Items) {
total += (item["fee_amount"] as number) ?? 0;
}
}
exclusiveStartKey = result.LastEvaluatedKey as
| Record<string, unknown>
| undefined;
} while (exclusiveStartKey);
return total;
}
}
// ─── Helpers ───
/** Map a raw DynamoDB item back to a typed FeeRecord. */
function itemToFeeRecord(item: Record<string, unknown>): FeeRecord {
return {
transaction_id: item["transaction_id"] as string,
checkout_id: item["checkout_id"] as string,
order_id: item["order_id"] as string,
gross_amount: item["gross_amount"] as number,
fee_rate: item["fee_rate"] as number,
fee_amount: item["fee_amount"] as number,
currency: item["currency"] as string,
wallet_address: item["wallet_address"] as string,
status: item["status"] as FeeRecord["status"],
created_at: item["created_at"] as string,
};
}