mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
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
144 lines
5.3 KiB
TypeScript
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(),
|
|
});
|
|
}
|
|
}
|
|
|