feat: complete TASK-WP2-04 credit ledger
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
AccountProfileUpdateResponseSchema,
|
||||
AccountSettingsResponseSchema,
|
||||
AdminAuthenticatedUserSchema,
|
||||
AdminCreditParamsSchema,
|
||||
AdminLoginCompleteRequestSchema,
|
||||
AdminLoginCompleteResponseSchema,
|
||||
AdminLoginSendRequestSchema,
|
||||
@@ -21,6 +22,16 @@ import {
|
||||
CorrelationIdSchema,
|
||||
AuthenticatedUserSchema,
|
||||
CreditSummarySchema,
|
||||
CreditAdjustmentHeadersSchema,
|
||||
CreditAdjustmentRequestSchema,
|
||||
CreditAdjustmentResponseSchema,
|
||||
CreditBalanceResponseSchema,
|
||||
CreditEntryStatusSchema,
|
||||
CreditEntryTypeSchema,
|
||||
CreditLedgerEntrySchema,
|
||||
CreditLedgerQuerySchema,
|
||||
CreditLedgerResponseSchema,
|
||||
CreditReferenceTypeSchema,
|
||||
CsrfHeadersSchema,
|
||||
ErrorDetailsSchema,
|
||||
ErrorEnvelopeSchema,
|
||||
@@ -70,6 +81,10 @@ import {
|
||||
type AdminLoginSendRequest,
|
||||
type AccountDeletionCompleteRequest,
|
||||
type AccountProfileUpdateRequest,
|
||||
type AdminCreditParams,
|
||||
type CreditAdjustmentHeaders,
|
||||
type CreditAdjustmentRequest,
|
||||
type CreditLedgerQuery,
|
||||
type LoginCompleteRequest,
|
||||
type LoginSendRequest,
|
||||
type FailedEmptyTrashRequest,
|
||||
@@ -98,6 +113,8 @@ import {
|
||||
type BrowserUnsupportedReason,
|
||||
} from "./browser-support.js";
|
||||
import { EventHub } from "./event-hub.js";
|
||||
import { CreditError } from "./credit-errors.js";
|
||||
import type { CreditService } from "./credits.js";
|
||||
import type { PublicAssetResolver } from "./local-data-root.js";
|
||||
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||
import { ProjectError } from "./project-errors.js";
|
||||
@@ -125,6 +142,7 @@ export interface CreateAppOptions {
|
||||
browserGate?: boolean;
|
||||
browserSupportRelease?: BrowserSupportRelease;
|
||||
browserSupportSecret?: Buffer;
|
||||
credits?: CreditService;
|
||||
eventHub?: EventHub;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
@@ -215,6 +233,21 @@ function projectFailure(reply: FastifyReply, correlationId: string, error: unkno
|
||||
return reply.code(mapping[error.code]).send(null);
|
||||
}
|
||||
|
||||
function creditFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
||||
if (!(error instanceof CreditError)) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
|
||||
}
|
||||
if (error.code === "credit_operation_conflict") {
|
||||
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId }));
|
||||
}
|
||||
const status = error.code === "credit_request_invalid"
|
||||
? 400
|
||||
: error.code === "credit_insufficient" || error.code === "credit_invariant_failed"
|
||||
? 409
|
||||
: 404;
|
||||
return reply.code(status).send(null);
|
||||
}
|
||||
|
||||
type ProjectSummaryView = ReturnType<ProjectService["listProjects"]>[number];
|
||||
type ProjectDetailView = ReturnType<ProjectService["getProject"]>;
|
||||
|
||||
@@ -331,6 +364,17 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
AdminLoginCompleteResponseSchema,
|
||||
AdminSessionResponseSchema,
|
||||
CreditSummarySchema,
|
||||
CreditEntryTypeSchema,
|
||||
CreditEntryStatusSchema,
|
||||
CreditReferenceTypeSchema,
|
||||
CreditBalanceResponseSchema,
|
||||
CreditLedgerEntrySchema,
|
||||
CreditLedgerQuerySchema,
|
||||
CreditLedgerResponseSchema,
|
||||
AdminCreditParamsSchema,
|
||||
CreditAdjustmentHeadersSchema,
|
||||
CreditAdjustmentRequestSchema,
|
||||
CreditAdjustmentResponseSchema,
|
||||
CsrfHeadersSchema,
|
||||
AccountSettingsResponseSchema,
|
||||
AccountProfileUpdateRequestSchema,
|
||||
@@ -952,6 +996,199 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/me/credits",
|
||||
{
|
||||
schema: {
|
||||
operationId: "getMyCredits",
|
||||
response: {
|
||||
200: Type.Ref(CreditBalanceResponseSchema),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Credits"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!options.registration || !options.credits) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const account = options.credits.readAccount(session.userId);
|
||||
return {
|
||||
available_balance: account.availableBalance,
|
||||
reserved_balance: account.reservedBalance,
|
||||
updated_at: account.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
return creditFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/me/credit-ledger",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "getMyCreditLedger",
|
||||
querystring: Type.Ref(CreditLedgerQuerySchema),
|
||||
response: {
|
||||
200: Type.Ref(CreditLedgerResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Credits"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.credits) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const query = request.query as CreditLedgerQuery;
|
||||
const ledger = options.credits.listLedger({
|
||||
...(query.cursor ? { cursor: query.cursor } : {}),
|
||||
...(query.event_type ? { eventType: query.event_type } : {}),
|
||||
...(query.from ? { from: query.from } : {}),
|
||||
...(query.limit ? { limit: query.limit } : {}),
|
||||
...(query.to ? { to: query.to } : {}),
|
||||
userId: session.userId,
|
||||
});
|
||||
return {
|
||||
credits: {
|
||||
available_balance: ledger.account.availableBalance,
|
||||
reserved_balance: ledger.account.reservedBalance,
|
||||
},
|
||||
entries: ledger.entries.map((entry) => ({
|
||||
amount: entry.amount,
|
||||
available_after: entry.availableAfter,
|
||||
available_before: entry.availableBefore,
|
||||
created_at: entry.createdAt,
|
||||
entry_id: entry.entryId,
|
||||
entry_type: entry.entryType,
|
||||
model_id: entry.modelId,
|
||||
reason: entry.reason,
|
||||
reference_id: entry.referenceId,
|
||||
reference_type: entry.referenceType,
|
||||
reserved_after: entry.reservedAfter,
|
||||
reserved_before: entry.reservedBefore,
|
||||
status: entry.status,
|
||||
})),
|
||||
next_cursor: ledger.nextCursor,
|
||||
updated_at: ledger.account.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
return creditFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/admin/users/:userId/credits",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "getAdminUserCredits",
|
||||
params: Type.Ref(AdminCreditParamsSchema),
|
||||
response: {
|
||||
200: Type.Ref(CreditBalanceResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Admin Credits"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.credits) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
||||
const session = token ? options.registration.readAdminSession(token) : undefined;
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const account = options.credits.readAccount((request.params as AdminCreditParams).userId);
|
||||
return {
|
||||
available_balance: account.availableBalance,
|
||||
reserved_balance: account.reservedBalance,
|
||||
updated_at: account.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
return creditFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/admin/users/:userId/credit-adjustments",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(CreditAdjustmentRequestSchema),
|
||||
headers: Type.Ref(CreditAdjustmentHeadersSchema),
|
||||
operationId: "adjustAdminUserCredits",
|
||||
params: Type.Ref(AdminCreditParamsSchema),
|
||||
response: {
|
||||
200: Type.Ref(CreditAdjustmentResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
403: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
409: Type.Union([Type.Ref(ErrorEnvelopeSchema), Type.Null()]),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Admin Credits"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.credits) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
||||
const headers = request.headers as CreditAdjustmentHeaders;
|
||||
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const admin = options.registration.authorizeAdminMutation({
|
||||
csrfToken: headers["x-csrf-token"],
|
||||
sessionToken: token,
|
||||
});
|
||||
const body = request.body as CreditAdjustmentRequest;
|
||||
const adjusted = options.credits.adjustAvailable({
|
||||
adjustmentId: body.adjustment_id,
|
||||
adminId: admin.userId,
|
||||
amount: body.amount,
|
||||
idempotencyKey: headers["idempotency-key"],
|
||||
reason: body.reason,
|
||||
userId: (request.params as AdminCreditParams).userId,
|
||||
});
|
||||
return {
|
||||
adjustment_id: adjusted.adjustmentId,
|
||||
available_balance: adjusted.availableBalance,
|
||||
reserved_balance: adjusted.reservedBalance,
|
||||
status: adjusted.status,
|
||||
};
|
||||
} catch (error) {
|
||||
return error instanceof RegistrationError
|
||||
? registrationFailure(reply, request.id, error)
|
||||
: creditFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/account/deletion/send",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user