stack/apps/backend/scripts/verify-data-integrity/stripe-payout-integrity.ts
BilalG1 609579abab
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
feat(hexclave): PR 3 — native @hexclave/* source rename + delete dual-publish wiring (#1482)
2026-05-29 15:21:59 -07:00

144 lines
5.3 KiB
TypeScript

import type { Tenancy } from "@/lib/tenancies";
import { getStripeForAccount } from "@/lib/stripe";
import type { Transaction } from "@hexclave/shared/dist/interface/crud/transactions";
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
import { deindent } from "@hexclave/shared/dist/utils/strings";
import { urlString } from "@hexclave/shared/dist/utils/urls";
import type { ExpectStatusCode } from "./api";
export async function fetchAllTransactionsForProject(options: {
projectId: string,
expectStatusCode: ExpectStatusCode,
}) {
const transactions: Transaction[] = [];
let cursor: string | null = null;
do {
const params = new URLSearchParams({ limit: "200" });
if (cursor) params.set("cursor", cursor);
const endpoint = urlString`/api/v1/internal/payments/transactions` + (params.toString() ? `?${params.toString()}` : "");
const response = await options.expectStatusCode(200, endpoint, {
method: "GET",
headers: {
"x-stack-project-id": options.projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
}) as { transactions: Transaction[], next_cursor: string | null };
transactions.push(...response.transactions);
cursor = response.next_cursor;
} while (cursor);
return transactions;
}
function parseMoneyAmountToMinorUnits(amount: string, decimals: number): bigint {
const [wholePart, fractionalPart = ""] = amount.split(".");
if (fractionalPart.length > decimals) {
throw new HexclaveAssertionError("Money amount has too many decimals", { amount, decimals });
}
const paddedFraction = fractionalPart.padEnd(decimals, "0");
return BigInt(`${wholePart}${paddedFraction}`);
}
function formatMinorUnitsToMoneyString(amount: bigint, decimals: number): string {
const isNegative = amount < 0n;
const absolute = isNegative ? -amount : amount;
const absoluteString = absolute.toString().padStart(decimals + 1, "0");
const wholePart = absoluteString.slice(0, -decimals);
const fractionalPart = absoluteString.slice(-decimals).replace(/0+$/, "");
const rendered = fractionalPart.length > 0 ? `${wholePart}.${fractionalPart}` : wholePart;
return isNegative ? `-${rendered}` : rendered;
}
function sumMoneyTransfersUsdMinorUnits(transactions: Transaction[]): bigint {
let total = 0n;
for (const transaction of transactions) {
for (const entry of transaction.entries) {
if (entry.type !== "money_transfer") continue;
total += parseMoneyAmountToMinorUnits(entry.net_amount.USD, 2);
}
}
return total;
}
type StripeBalanceTransactionList = {
data: Array<{
id: string,
amount: number,
currency: string,
reporting_category?: string | null,
}>,
has_more: boolean,
};
async function fetchStripeBalanceTransactionTotalUsdMinorUnits(options: {
tenancy: Tenancy,
stripeAccountId: string,
}): Promise<bigint> {
const stripe = await getStripeForAccount({
tenancy: options.tenancy,
accountId: options.stripeAccountId,
});
let total = 0n;
const includeCategories = new Set([
"charge",
"refund",
"dispute",
"dispute_reversal",
"partial_capture_reversal",
]);
let startingAfter: string | undefined = undefined;
do {
const page: StripeBalanceTransactionList = await stripe.balanceTransactions.list({
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
});
for (const balanceTransaction of page.data) {
if (balanceTransaction.currency !== "usd") continue;
if (!balanceTransaction.reporting_category) continue;
if (!includeCategories.has(balanceTransaction.reporting_category)) continue;
total += BigInt(balanceTransaction.amount);
}
startingAfter = page.has_more ? page.data.at(-1)?.id : undefined;
} while (startingAfter);
return total;
}
export async function verifyStripePayoutIntegrity(options: {
projectId: string,
tenancy: Tenancy,
stripeAccountId: string,
expectStatusCode: ExpectStatusCode,
}) {
if (options.projectId === '6fbbf22e-f4b2-4c6e-95a1-beab6fa41063') {
// Dummy project doesn't have a real stripe account, so we skip the verification.
return;
}
const transactions = await fetchAllTransactionsForProject({
projectId: options.projectId,
expectStatusCode: options.expectStatusCode,
});
const moneyTransferTotalUsdMinor = sumMoneyTransfersUsdMinorUnits(transactions);
const stripeBalanceTransactionTotalUsdMinor = await fetchStripeBalanceTransactionTotalUsdMinorUnits({
tenancy: options.tenancy,
stripeAccountId: options.stripeAccountId,
});
if (moneyTransferTotalUsdMinor !== stripeBalanceTransactionTotalUsdMinor) {
throw new HexclaveAssertionError(deindent`
Stripe balance transaction mismatch for project ${options.projectId}.
Money transfers total USD ${formatMinorUnitsToMoneyString(moneyTransferTotalUsdMinor, 2)} vs Stripe balance transactions USD ${formatMinorUnitsToMoneyString(stripeBalanceTransactionTotalUsdMinor, 2)}.
`, {
projectId: options.projectId,
moneyTransferTotalUsdMinor: moneyTransferTotalUsdMinor.toString(),
stripeBalanceTransactionTotalUsdMinor: stripeBalanceTransactionTotalUsdMinor.toString(),
});
}
}