feat: manage owner usage history records

This commit is contained in:
Wyndham ARR
2026-08-03 16:43:27 +08:00
parent 393b841023
commit 84443d5dba
17 changed files with 798 additions and 102 deletions

View File

@@ -26,12 +26,24 @@ const legacyDownPath = path.join(
"migrations",
"002_legacy_import_and_bookings.down.sql"
);
const deleteUpPath = path.join(
backendDirectory,
"migrations",
"003_delete_usage_record.up.sql"
);
const deleteDownPath = path.join(
backendDirectory,
"migrations",
"003_delete_usage_record.down.sql"
);
const [upSql, downSql, legacyUpSql, legacyDownSql] = await Promise.all([
const [upSql, downSql, legacyUpSql, legacyDownSql, deleteUpSql, deleteDownSql] = await Promise.all([
readFile(upPath, "utf8"),
readFile(downPath, "utf8"),
readFile(legacyUpPath, "utf8"),
readFile(legacyDownPath, "utf8")
readFile(legacyDownPath, "utf8"),
readFile(deleteUpPath, "utf8"),
readFile(deleteDownPath, "utf8")
]);
const checks = [];
@@ -166,6 +178,21 @@ record(
&& /^DROP FUNCTION condon\.create_usage_record_v2/gm.test(legacyDownSql)
&& /^DROP INDEX condon\.usage_records_source_location_key;$/gm.test(legacyDownSql)
);
record(
"delete migration is an internal transactional function",
/^CREATE FUNCTION condon\.delete_usage_record\(\s*p_usage_record_id uuid\s*\)/m.test(deleteUpSql)
&& /^RETURNS void$/m.test(deleteUpSql)
&& /^SECURITY INVOKER$/m.test(deleteUpSql)
&& deleteUpSql.includes("FOR UPDATE")
&& deleteUpSql.includes("DELETE FROM condon.entitlement_ledger")
&& deleteUpSql.includes("UPDATE condon.entitlement_periods")
&& /^REVOKE ALL ON FUNCTION condon\.delete_usage_record\(uuid\) FROM PUBLIC;$/m.test(deleteUpSql)
);
record(
"delete migration down reverses its function",
/^DROP FUNCTION condon\.delete_usage_record\(uuid\);$/m.test(deleteDownSql)
&& !/\bCASCADE\b/i.test(deleteDownSql)
);
for (const check of checks) {
process.stdout.write(

View File

@@ -166,6 +166,7 @@ export class ImportSnapshotRepository {
));
this.ownerById = new Map(this.owners.map(owner => [owner.id, owner]));
this.periodBalances = new Map();
this.periodInitialBalances = new Map();
this.periodYears = new Set();
this.idempotentResponses = new Map();
@@ -183,6 +184,7 @@ export class ImportSnapshotRepository {
const rows2025 = usageByRoomAndYear.get(`${room}|2025`) ?? [];
const carryForward = rows2025.length > 0 ? Number(rows2025.at(-1).balance) : 0;
this.periodYears.add(periodKey(owner.id, 2026));
this.periodInitialBalances.set(periodKey(owner.id, 2026), 15 + carryForward);
const used2026 = (usageByRoomAndYear.get(`${room}|2026`) ?? [])
.reduce((sum, record) => sum + Number(record.use), 0);
this.periodBalances.set(periodKey(owner.id, 2026), 15 + carryForward - used2026);
@@ -195,6 +197,7 @@ export class ImportSnapshotRepository {
for (const year of otherYears) {
const rows = usageByRoomAndYear.get(`${room}|${year}`) ?? [];
this.periodYears.add(periodKey(owner.id, year));
this.periodInitialBalances.set(periodKey(owner.id, year), 15);
this.periodBalances.set(
periodKey(owner.id, year),
15 - rows.reduce((sum, record) => sum + Number(record.use), 0)
@@ -252,7 +255,7 @@ export class ImportSnapshotRepository {
status: "ok",
database: "booking_test",
schema: "condon",
migrationVersion: "002_legacy_import_and_bookings:snapshot"
migrationVersion: "003_delete_usage_record:snapshot"
};
}
@@ -303,6 +306,38 @@ export class ImportSnapshotRepository {
return paginate(filtered.map(entry => ({ ...entry.publicRecord })), query);
}
rebuildPeriodBalance(ownerAccountId, periodYear) {
const key = periodKey(ownerAccountId, periodYear);
const entries = this.usageEntries
.filter(entry => entry.publicRecord.ownerAccountId === ownerAccountId && entry.periodYear === periodYear)
.sort((left, right) => {
const leftImported = left.sourceSheet !== null;
const rightImported = right.sourceSheet !== null;
if (leftImported !== rightImported) return leftImported ? -1 : 1;
if (leftImported) return sourceOrder(left, right);
return String(left.publicRecord.createdAt).localeCompare(String(right.publicRecord.createdAt))
|| left.publicRecord.id.localeCompare(right.publicRecord.id);
});
let balance = this.periodInitialBalances.get(key) ?? 15;
for (const entry of entries) {
balance -= entry.publicRecord.use;
entry.publicRecord.balance = balance;
}
this.periodBalances.set(key, balance);
}
async deleteUsageRecord(id) {
const index = this.usageEntries.findIndex(entry => entry.publicRecord.id === id);
if (index < 0) {
throw new ApiError(404, "NOT_FOUND", "The requested business record was not found");
}
const [removed] = this.usageEntries.splice(index, 1);
for (const [key, value] of this.idempotentResponses) {
if (value.response.id === id) this.idempotentResponses.delete(key);
}
this.rebuildPeriodBalance(removed.publicRecord.ownerAccountId, removed.periodYear);
}
async createUsageRecord(input) {
const previous = this.idempotentResponses.get(input.idempotencyKey);
if (previous) {

View File

@@ -17,6 +17,9 @@ const MIGRATION_CHECKSUM =
const MIGRATION_V2 = "002_legacy_import_and_bookings";
const MIGRATION_V2_CHECKSUM =
"cfc38024984c883490cb46ab8ab1fca2e72c67f10dc3a43c179b12a8e6f2405b";
const MIGRATION_V3 = "003_delete_usage_record";
const MIGRATION_V3_CHECKSUM =
"9110464c03ed18385fbc2da1d4369715fcbf83148d7a0f2550c25710a5f54148";
const expectedColumns = {
bookings: [
@@ -334,11 +337,12 @@ try {
calculate_multiplier: { volatility: "s" },
create_usage_record: { volatility: "v" },
create_usage_record_v2: { volatility: "v" },
delete_usage_record: { volatility: "v" },
ensure_booking_for_usage: { volatility: "v" },
open_entitlement_period: { volatility: "v" }
};
const functionsValid =
functionResult.rowCount === 5
functionResult.rowCount === 6
&& functionResult.rows.every(row =>
expectedFunctions[row.function_name]?.volatility === row.volatility
&& row.security_definer === false
@@ -371,13 +375,16 @@ try {
&& businessCounts.usage_record_count === 157
&& businessCounts.ledger_count === 547,
migrationVerified:
migrationResult.rowCount === 2
migrationResult.rowCount === 3
&& migrationResult.rows.some(row =>
row.version === MIGRATION_VERSION && row.checksum === MIGRATION_CHECKSUM
)
&& migrationResult.rows.some(row =>
row.version === MIGRATION_V2 && row.checksum === MIGRATION_V2_CHECKSUM
)
&& migrationResult.rows.some(row =>
row.version === MIGRATION_V3 && row.checksum === MIGRATION_V3_CHECKSUM
)
};
process.stdout.write(`${JSON.stringify({