Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/stellar-reconciliation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Stellar reconciliation

The reconciliation module records confirmed Stellar payments, matches them to internal invoices, and preserves every decision in an audit trail.

## Testnet polling

Set `STELLAR_RECONCILIATION_ACCOUNT` to the Stellar destination account monitored by the service. The service polls `STELLAR_HORIZON_URL` every 60 seconds and defaults to `https://horizon-testnet.stellar.org`. Horizon paging tokens provide idempotent continuation. Duplicate transaction hashes are ignored and recorded as retry-safe audit entries.

For webhook or queue consumers, send a confirmed payment to `POST /reconcile/stellar/transactions` with its transaction hash, destination account, amount, asset, and memo. The endpoint accepts the same payload shape as the Horizon adapter.

## Invoice lifecycle

Register an invoice with `POST /reconcile/stellar/invoice`.

```json
{
"invoiceId": "INV-2026-0001",
"expectedAmount": "125.5000000",
"destinationAccount": "GABC...DEST",
"paymentReference": "order-0001",
"assetCode": "XLM"
}
```

Matching requires destination account and asset equality. When an invoice has a payment reference, the incoming memo must match it. The invoice moves from `open` to `partial` or `paid`. Transactions without a matching invoice remain `unmatched`.

## Lookup and operations

`GET /reconcile/stellar/tx/:txid` returns the transaction and its decisions. `GET /reconcile/stellar/invoice/:invoiceId` returns the invoice and its decisions. `POST /reconcile/stellar/invoice/:invoiceId/reconcile` retries matching for accounting staff. Administrators can inspect unmatched transactions with `GET /reconcile/stellar/admin/unmatched` and audit records with `GET /reconcile/stellar/admin/audit`.

The admin endpoints require an authenticated administrator with verified two-factor authentication. All decision records include the invoice, transaction, decision, reason, attempt, and relevant matching metadata.
21 changes: 15 additions & 6 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { LoggerModule } from "./logging/logger.module";
// Modules – cache
import { CacheModule } from "./common/cache/cache.module";
import { BillingModule } from "./billing/billing.module";
import { ReconciliationModule } from "./reconciliation/reconciliation.module";

// Auth entities
import { User } from "./core/user/entities/user.entity";
Expand Down Expand Up @@ -108,6 +109,10 @@ import { WebhookDeadLetter } from "./infrastructure/webhooks/entities/webhook-de
import { UploadedFile } from "./infrastructure/file-upload/entities/uploaded-file.entity";
import { FileThumbnail } from "./infrastructure/file-upload/entities/file-thumbnail.entity";
import { FileScanResult } from "./infrastructure/file-upload/entities/file-scan-result.entity";
// Reconciliation entities
import { ReconciliationAudit } from "./reconciliation/entities/reconciliation-audit.entity";
import { ReconciliationInvoice } from "./reconciliation/entities/reconciliation-invoice.entity";
import { StellarTransaction } from "./reconciliation/entities/stellar-transaction.entity";
// Modules – webhooks
import { WebhookModule } from "./infrastructure/webhooks/webhook.module";
// Modules – file upload
Expand Down Expand Up @@ -141,7 +146,7 @@ import { GraphqlGatewayModule } from "./graphql/graphql.module";
throw new Error(
`Environment validation failed: ${errors
.map((e) => Object.values(e.constraints || {}).join(", "))
.join(", ")}`,
.join(", ")}`
);
}
return validatedConfig;
Expand Down Expand Up @@ -202,9 +207,12 @@ import { GraphqlGatewayModule } from "./graphql/graphql.module";
WebhookEvent,
WebhookDelivery,
WebhookDeadLetter,
UploadedFile,
FileThumbnail,
FileScanResult,
UploadedFile,
FileThumbnail,
FileScanResult,
ReconciliationAudit,
ReconciliationInvoice,
StellarTransaction,
],
synchronize: true,
logging: true,
Expand Down Expand Up @@ -253,6 +261,7 @@ import { GraphqlGatewayModule } from "./graphql/graphql.module";
}),
}),
BillingModule,
ReconciliationModule,
],

controllers: [AppController],
Expand Down Expand Up @@ -284,7 +293,7 @@ import { GraphqlGatewayModule } from "./graphql/graphql.module";
export class AppModule implements NestModule, OnModuleInit {
constructor(
@Inject(SubmissionVerifierService)
private readonly verifier: SubmissionVerifierService,
private readonly verifier: SubmissionVerifierService
) {}

configure(consumer: MiddlewareConsumer) {
Expand All @@ -293,7 +302,7 @@ export class AppModule implements NestModule, OnModuleInit {
consumer
.apply(
(req, res, next) => loggingMiddleware.use(req, res, next),
ProfilingMiddleware,
ProfilingMiddleware
)
.forRoutes("*");
}
Expand Down
100 changes: 100 additions & 0 deletions src/reconciliation/dto/reconciliation.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsDateString,
IsNumberString,
IsObject,
IsOptional,
IsString,
Length,
MaxLength,
} from "class-validator";

export class CreateReconciliationInvoiceDto {
@ApiProperty({ example: "INV-2026-0001" })
@IsString()
@MaxLength(255)
invoiceId: string;

@ApiProperty({ example: "125.5000000" })
@IsNumberString()
expectedAmount: string;

@ApiProperty({ example: "GABC...DEST" })
@IsString()
@Length(56, 56)
destinationAccount: string;

@ApiPropertyOptional({ example: "order-0001" })
@IsOptional()
@IsString()
@MaxLength(128)
paymentReference?: string;

@ApiPropertyOptional({ example: "XLM" })
@IsOptional()
@IsString()
@MaxLength(12)
assetCode?: string;

@ApiPropertyOptional({ type: Object })
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

export class IngestStellarTransactionDto {
@ApiProperty({ example: "a transaction hash" })
@IsString()
@MaxLength(128)
transactionId: string;

@ApiPropertyOptional({ example: "123456" })
@IsOptional()
@IsString()
ledger?: string;

@ApiPropertyOptional({ example: "GABC...SOURCE" })
@IsOptional()
@IsString()
@Length(56, 56)
sourceAccount?: string;

@ApiProperty({ example: "GABC...DEST" })
@IsString()
@Length(56, 56)
destinationAccount: string;

@ApiProperty({ example: "125.5000000" })
@IsNumberString()
amount: string;

@ApiPropertyOptional({ example: "XLM" })
@IsOptional()
@IsString()
@MaxLength(12)
assetCode?: string;

@ApiPropertyOptional({ example: "order-0001" })
@IsOptional()
@IsString()
@MaxLength(128)
memo?: string;

@ApiPropertyOptional({ example: "2026-08-21T12:00:00.000Z" })
@IsOptional()
@IsDateString()
observedAt?: string;

@ApiPropertyOptional({ type: Object })
@IsOptional()
@IsObject()
rawPayload?: Record<string, unknown>;
}

export class ManualReconciliationDto {
@ApiPropertyOptional({ example: "manual-review-123" })
@IsOptional()
@IsString()
@MaxLength(255)
note?: string;
}
33 changes: 33 additions & 0 deletions src/reconciliation/entities/reconciliation-audit.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Column, Entity, Index } from "typeorm";
import { BaseEntity } from "../../common/database/entities/base.entity";

export enum ReconciliationDecision {
MATCHED = "matched",
PARTIAL = "partial",
UNMATCHED = "unmatched",
FAILED = "failed",
RETRY = "retry",
}

@Entity("reconciliation_audits")
@Index(["invoiceId", "createdAt"])
@Index(["transactionId", "createdAt"])
export class ReconciliationAudit extends BaseEntity {
@Column({ type: "varchar", length: 255, nullable: true })
invoiceId: string;

@Column({ type: "varchar", length: 128, nullable: true })
transactionId: string;

@Column({ type: "varchar", length: 16 })
decision: ReconciliationDecision;

@Column({ type: "text", nullable: true })
reason: string;

@Column({ type: "integer", default: 0 })
attempt: number;

@Column({ type: "jsonb", nullable: true })
metadata: Record<string, unknown>;
}
42 changes: 42 additions & 0 deletions src/reconciliation/entities/reconciliation-invoice.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Column, Entity, Index } from "typeorm";
import { BaseEntity } from "../../common/database/entities/base.entity";

export enum ReconciliationInvoiceStatus {
OPEN = "open",
PARTIAL = "partial",
PAID = "paid",
FAILED = "failed",
}

@Entity("reconciliation_invoices")
@Index(["invoiceId"], { unique: true })
@Index(["status"])
export class ReconciliationInvoice extends BaseEntity {
@Column({ type: "varchar", length: 255 })
invoiceId: string;

@Column({ type: "numeric", precision: 30, scale: 7 })
expectedAmount: string;

@Column({ type: "numeric", precision: 30, scale: 7, default: "0" })
paidAmount: string;

@Column({ type: "varchar", length: 12, default: "XLM" })
assetCode: string;

@Column({ type: "varchar", length: 64 })
destinationAccount: string;

@Column({ type: "varchar", length: 128, nullable: true })
paymentReference: string;

@Column({
type: "varchar",
length: 16,
default: ReconciliationInvoiceStatus.OPEN,
})
status: ReconciliationInvoiceStatus;

@Column({ type: "jsonb", nullable: true })
metadata: Record<string, unknown>;
}
55 changes: 55 additions & 0 deletions src/reconciliation/entities/stellar-transaction.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Column, Entity, Index } from "typeorm";
import { BaseEntity } from "../../common/database/entities/base.entity";

export enum StellarTransactionStatus {
UNMATCHED = "unmatched",
PARTIAL = "partial",
MATCHED = "matched",
FAILED = "failed",
}

@Entity("stellar_reconciliation_transactions")
@Index(["transactionId"], { unique: true })
@Index(["status"])
@Index(["paymentReference"])
export class StellarTransaction extends BaseEntity {
@Column({ type: "varchar", length: 128 })
transactionId: string;

@Column({ type: "bigint", nullable: true })
ledger: string;

@Column({ type: "varchar", length: 64, nullable: true })
sourceAccount: string;

@Column({ type: "varchar", length: 64 })
destinationAccount: string;

@Column({ type: "numeric", precision: 30, scale: 7 })
amount: string;

@Column({ type: "varchar", length: 12, default: "XLM" })
assetCode: string;

@Column({ type: "varchar", length: 128, nullable: true })
memo: string;

@Column({ type: "varchar", length: 128, nullable: true })
paymentReference: string;

@Column({
type: "varchar",
length: 16,
default: StellarTransactionStatus.UNMATCHED,
})
status: StellarTransactionStatus;

@Column({ type: "text", nullable: true })
failureReason: string;

@Column({ type: "timestamptz", nullable: true })
observedAt: Date;

@Column({ type: "jsonb", nullable: true })
rawPayload: Record<string, unknown>;
}
13 changes: 13 additions & 0 deletions src/reconciliation/horizon-polling.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Injectable } from "@nestjs/common";
import { Interval } from "@nestjs/schedule";
import { ReconciliationService } from "./reconciliation.service";

@Injectable()
export class HorizonPollingService {
constructor(private readonly reconciliationService: ReconciliationService) {}

@Interval(60_000)
poll() {
return this.reconciliationService.pollHorizon();
}
}
Loading
Loading