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

@@ -6,7 +6,7 @@ TypeScript/Fastify API and PostgreSQL database layer for the current CONDO owner
- Database: existing `booking_test`.
- Isolated schema: `condon`.
- Migrations: `001_create_condon_schema`, `002_legacy_import_and_bookings`.
- Migrations: `001_create_condon_schema`, `002_legacy_import_and_bookings`, `003_delete_usage_record`.
- Reference data: 11 room types.
- Imported latest `(2)` batch: 388 owners, 389 periods, 155 bookings, 157 usage records and 547 ledger rows.
- Historical data import: `legacy-016dc36d15cc40a5` completed after local preflight and read-only verification.
@@ -31,6 +31,7 @@ The database functions are:
- `condon.open_entitlement_period`
- `condon.create_usage_record`
- `condon.create_usage_record_v2`
- `condon.delete_usage_record`
`create_usage_record_v2` is the authoritative new-record write path. It calculates Night, Multiplier, Use and Balance, locks the entitlement period and writes the usage row, ledger row and new balance atomically. Historical rows use `legacy-source`, preserve source Use/Balance/Room/raw room type, and may have a null multiplier.
@@ -47,6 +48,7 @@ The database functions are:
| GET | `/owner-accounts/:id` | Account detail and period balance |
| GET | `/usage-records` | Searchable, paginated usage history |
| POST | `/usage-records` | Transactional usage deduction |
| DELETE | `/usage-records/:id` | Transactional usage deletion and balance restoration |
| GET | `/dashboard` | Annual totals and room-type/month aggregates |
Runtime OpenAPI documentation is available at `/docs`.
@@ -130,7 +132,7 @@ The database integration test covers all 100 automatic room-type combinations, b
## Rollback
The latest migration rollback is [002_legacy_import_and_bookings.down.sql](./migrations/002_legacy_import_and_bookings.down.sql). Do not run it after business data has been imported. The original empty-schema rollback remains [001_create_condon_schema.down.sql](./migrations/001_create_condon_schema.down.sql).
The latest migration rollback is [003_delete_usage_record.down.sql](./migrations/003_delete_usage_record.down.sql). Do not run it after business data has been imported. The previous legacy rollback remains [002_legacy_import_and_bookings.down.sql](./migrations/002_legacy_import_and_bookings.down.sql), and the original empty-schema rollback remains [001_create_condon_schema.down.sql](./migrations/001_create_condon_schema.down.sql).
Do not run rollback after business data has been imported. Rollback is an explicit operator action and is not exposed as a normal npm command.
@@ -139,5 +141,5 @@ Do not run rollback after business data has been imported. Rollback is an explic
- Importing a newer workbook without a new preflight batch.
- Switching the static frontend to the API.
- Multi-user identities, roles and backend-restart-persistent sessions.
- Usage cancellation, reversal and manual balance adjustment.
- Manual balance adjustment.
- Cross-entitlement-year stays.

View File

@@ -0,0 +1,2 @@
REVOKE ALL ON FUNCTION condon.delete_usage_record(uuid) FROM PUBLIC;
DROP FUNCTION condon.delete_usage_record(uuid);

View File

@@ -0,0 +1,167 @@
-- Add an atomic usage-record deletion path. The operation restores the
-- entitlement period and reflows stored balance snapshots for later rows.
CREATE FUNCTION condon.delete_usage_record(
p_usage_record_id uuid
)
RETURNS void
LANGUAGE plpgsql
VOLATILE
SECURITY INVOKER
SET search_path = pg_catalog
AS $function$
DECLARE
v_usage condon.usage_records%ROWTYPE;
v_period condon.entitlement_periods%ROWTYPE;
v_initial_balance integer;
v_now timestamptz := pg_catalog.clock_timestamp();
BEGIN
IF p_usage_record_id IS NULL THEN
RAISE EXCEPTION USING
ERRCODE = '22023',
MESSAGE = 'CONDON_USAGE_RECORD_ID_REQUIRED';
END IF;
SELECT *
INTO v_usage
FROM condon.usage_records
WHERE id = p_usage_record_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION USING
ERRCODE = 'P0002',
MESSAGE = 'CONDON_USAGE_RECORD_NOT_FOUND';
END IF;
SELECT *
INTO v_period
FROM condon.entitlement_periods
WHERE id = v_usage.entitlement_period_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION USING
ERRCODE = 'P0002',
MESSAGE = 'CONDON_ENTITLEMENT_PERIOD_NOT_FOUND';
END IF;
DELETE FROM condon.entitlement_ledger
WHERE usage_record_id = v_usage.id;
DELETE FROM condon.usage_records
WHERE id = v_usage.id;
v_initial_balance := v_period.annual_grant + v_period.carry_forward;
WITH ordered AS (
SELECT
ur.id,
(
v_initial_balance
- COALESCE(
SUM(ur.use_nights) OVER (
ORDER BY
CASE WHEN ur.source_sheet IS NULL THEN 1 ELSE 0 END,
ur.source_sheet NULLS LAST,
ur.source_sequence NULLS LAST,
ur.source_row NULLS LAST,
ur.created_at ASC,
ur.id ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
0
)
)::integer AS balance_before,
(
v_initial_balance
- SUM(ur.use_nights) OVER (
ORDER BY
CASE WHEN ur.source_sheet IS NULL THEN 1 ELSE 0 END,
ur.source_sheet NULLS LAST,
ur.source_sequence NULLS LAST,
ur.source_row NULLS LAST,
ur.created_at ASC,
ur.id ASC
)
)::integer AS balance_after
FROM condon.usage_records AS ur
WHERE ur.entitlement_period_id = v_period.id
)
UPDATE condon.usage_records AS ur
SET
balance_before = ordered.balance_before,
balance_after = ordered.balance_after
FROM ordered
WHERE ur.id = ordered.id;
WITH ordered AS (
SELECT
ur.id,
(
v_initial_balance
- COALESCE(
SUM(ur.use_nights) OVER (
ORDER BY
CASE WHEN ur.source_sheet IS NULL THEN 1 ELSE 0 END,
ur.source_sheet NULLS LAST,
ur.source_sequence NULLS LAST,
ur.source_row NULLS LAST,
ur.created_at ASC,
ur.id ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
0
)
)::integer AS balance_before,
(
v_initial_balance
- SUM(ur.use_nights) OVER (
ORDER BY
CASE WHEN ur.source_sheet IS NULL THEN 1 ELSE 0 END,
ur.source_sheet NULLS LAST,
ur.source_sequence NULLS LAST,
ur.source_row NULLS LAST,
ur.created_at ASC,
ur.id ASC
)
)::integer AS balance_after
FROM condon.usage_records AS ur
WHERE ur.entitlement_period_id = v_period.id
)
UPDATE condon.entitlement_ledger AS ledger
SET
balance_before = ordered.balance_before,
balance_after = ordered.balance_after
FROM ordered
WHERE ledger.usage_record_id = ordered.id
AND ledger.entry_type = 'usage';
UPDATE condon.entitlement_periods
SET
current_balance = (
v_initial_balance
- COALESCE(
(
SELECT SUM(ur.use_nights)
FROM condon.usage_records AS ur
WHERE ur.entitlement_period_id = v_period.id
),
0
)
)::integer,
row_version = row_version + 1,
updated_at = v_now
WHERE id = v_period.id;
DELETE FROM condon.bookings AS booking
WHERE booking.confirmation_no = v_usage.confirmation_no
AND NOT EXISTS (
SELECT 1
FROM condon.usage_records AS ur
WHERE ur.confirmation_no = booking.confirmation_no
);
END;
$function$;
REVOKE ALL ON FUNCTION condon.delete_usage_record(uuid) FROM PUBLIC;

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({

View File

@@ -31,6 +31,9 @@ const ownerQuerySchema = z.object({
const ownerParamsSchema = z.object({
id: z.string().uuid()
});
const usageRecordParamsSchema = z.object({
id: z.string().uuid()
});
const ownerDetailQuerySchema = z.object({
year: z.coerce.number().int().min(2000).max(9999).default(currentYear)
});
@@ -193,7 +196,7 @@ export async function buildApp(
"http://localhost:4173"
],
credentials: true,
methods: ["GET", "POST"]
methods: ["GET", "POST", "DELETE"]
});
const publicAuthPaths = new Set([
@@ -517,6 +520,30 @@ export async function buildApp(
return reply.code(201).send(result);
});
app.delete("/usage-records/:id", {
schema: {
tags: ["usage-records"],
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", format: "uuid" }
}
},
response: {
204: { type: "null" },
400: errorResponseSchema,
404: errorResponseSchema,
409: errorResponseSchema,
500: errorResponseSchema
}
}
}, async (request, reply) => {
const params = parse(usageRecordParamsSchema, request.params);
await options.repository.deleteUsageRecord(params.id);
return reply.code(204).send();
});
app.get("/dashboard", {
schema: {
tags: ["dashboard"],

View File

@@ -43,6 +43,7 @@ export function mapDatabaseError(error: unknown): ApiError {
if (
message === "CONDON_OWNER_ACCOUNT_NOT_FOUND"
|| message === "CONDON_ENTITLEMENT_PERIOD_NOT_FOUND"
|| message === "CONDON_USAGE_RECORD_NOT_FOUND"
|| message.endsWith("_ROOM_TYPE_NOT_FOUND")
) {
return new ApiError(404, "NOT_FOUND", "The requested business record was not found");

View File

@@ -353,6 +353,17 @@ export class PostgresCondoRepository implements CondoRepository {
}
}
async deleteUsageRecord(id: string): Promise<void> {
try {
await this.pool.query(
"SELECT condon.delete_usage_record($1::uuid)",
[id]
);
} catch (error) {
throw mapDatabaseError(error);
}
}
async getDashboard(periodYear: number): Promise<DashboardResult> {
const client = await this.pool.connect();
try {

View File

@@ -18,6 +18,7 @@ export interface CondoRepository {
getOwnerAccount(id: string, periodYear: number): Promise<OwnerAccount | null>;
listUsageRecords(query: UsageRecordQuery): Promise<UsageRecordList>;
createUsageRecord(input: CreateUsageRecordInput): Promise<UsageRecord>;
deleteUsageRecord(id: string): Promise<void>;
getDashboard(periodYear: number): Promise<DashboardResult>;
close(): Promise<void>;
}

View File

@@ -84,6 +84,7 @@ function createRepository(
createdAt: "2026-07-29T00:00:00.000Z"
};
},
async deleteUsageRecord() {},
async getDashboard(periodYear: number) {
return {
periodYear,
@@ -389,6 +390,34 @@ test("business errors retain safe status and code", async t => {
assert.equal(JSON.stringify(response.json()).includes("password"), false);
});
test("usage deletion validates the record id and returns no content", async t => {
let captured: string | undefined;
const repository = createRepository({
async deleteUsageRecord(id) {
captured = id;
}
});
const { app, cookie } = await buildAuthenticatedApp(repository);
t.after(() => app.close());
const response = await app.inject({
method: "DELETE",
url: `/usage-records/${usageId}`,
headers: { cookie }
});
assert.equal(response.statusCode, 204);
assert.equal(response.body, "");
assert.equal(captured, usageId);
const malformed = await app.inject({
method: "DELETE",
url: "/usage-records/not-a-uuid",
headers: { cookie }
});
assert.equal(malformed.statusCode, 400);
assert.equal(malformed.json().error.code, "VALIDATION_ERROR");
});
test("dashboard and OpenAPI expose the planned surface", async t => {
const { app, cookie } = await buildAuthenticatedApp(createRepository());
t.after(() => app.close());
@@ -412,6 +441,7 @@ test("dashboard and OpenAPI expose the planned surface", async t => {
"/owner-accounts",
"/owner-accounts/{id}",
"/room-types",
"/usage-records"
"/usage-records",
"/usage-records/{id}"
]);
});