feat(billing): add products subscriptions orders and usage
This commit is contained in:
@@ -1,32 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireAdminSession } from "@/lib/admin/auth";
|
||||
import { runCodeRpc } from "@/lib/admin/codes";
|
||||
import {
|
||||
adminErrorResponse,
|
||||
invalidQueryResponse,
|
||||
requireHighRiskAdminMutation,
|
||||
requestId,
|
||||
} from "@/lib/admin/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
const updateCodeSchema = z.object({
|
||||
note: z.string().trim().max(500).nullable().optional(),
|
||||
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
|
||||
}).refine((value) => "note" in value || "expiresAt" in value, {
|
||||
message: "至少提供一个可修改字段",
|
||||
const revokeCodeSchema = z.object({
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
const updateCodeSchema = z
|
||||
.object({
|
||||
note: z.string().trim().max(500).nullable().optional(),
|
||||
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
})
|
||||
.refine((value) => "note" in value || "expiresAt" in value, {
|
||||
message: "至少提供一个可修改字段",
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await requireAdminSession("write");
|
||||
const session = await requireHighRiskAdminMutation(
|
||||
request,
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
const parsedParams = paramsSchema.safeParse(await context.params);
|
||||
const parsedBody = updateCodeSchema.safeParse(await request.json().catch(() => null));
|
||||
const parsedBody = updateCodeSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsedParams.success || !parsedBody.success) {
|
||||
return invalidQueryResponse();
|
||||
}
|
||||
@@ -41,6 +52,7 @@ export async function PATCH(
|
||||
p_note: body.note ?? null,
|
||||
p_set_expires_at: "expiresAt" in body,
|
||||
p_expires_at: body.expiresAt ?? null,
|
||||
p_reason: body.reason,
|
||||
},
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
@@ -54,14 +66,20 @@ export async function DELETE(
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await requireAdminSession("write");
|
||||
const session = await requireHighRiskAdminMutation(
|
||||
request,
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
const parsed = paramsSchema.safeParse(await context.params);
|
||||
if (!parsed.success) return invalidQueryResponse();
|
||||
const parsedBody = revokeCodeSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsed.success || !parsedBody.success) return invalidQueryResponse();
|
||||
const rows = await runCodeRpc(
|
||||
"admin_revoke_redemption_code",
|
||||
session,
|
||||
requestId(request),
|
||||
{ p_code_id: parsed.data.id },
|
||||
{ p_code_id: parsed.data.id, p_reason: parsedBody.data.reason },
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireAdminSession } from "@/lib/admin/auth";
|
||||
import { mapCode, runCodeRpc, type RedemptionCodeRecord } from "@/lib/admin/codes";
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import {
|
||||
mapCode,
|
||||
runCodeRpc,
|
||||
type RedemptionCodeRecord,
|
||||
} from "@/lib/admin/codes";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import {
|
||||
adminErrorResponse,
|
||||
invalidQueryResponse,
|
||||
parseListQuery,
|
||||
requireHighRiskAdminMutation,
|
||||
requestId,
|
||||
} from "@/lib/admin/http";
|
||||
import {
|
||||
@@ -23,6 +28,7 @@ const createCodesSchema = z.object({
|
||||
count: z.number().int().min(1).max(100),
|
||||
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
|
||||
note: z.string().trim().max(500).nullable().optional(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
type CodeRow = {
|
||||
@@ -59,7 +65,7 @@ function serializedCodeRow(row: CodeRow) {
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
await requirePermission("billing.orders.read");
|
||||
const parsed = parseListQuery(request);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const { page, pageSize, sort, order, q, status } = parsed.data;
|
||||
@@ -67,12 +73,19 @@ export async function GET(request: Request) {
|
||||
const conditions: string[] = [];
|
||||
if (q) {
|
||||
values.push(`%${q}%`);
|
||||
conditions.push(`(c.code_mask ilike $${values.length} or c.note ilike $${values.length})`);
|
||||
conditions.push(
|
||||
`(c.code_mask ilike $${values.length} or c.note ilike $${values.length})`,
|
||||
);
|
||||
}
|
||||
if (status && ["available", "expired", "redeemed", "revoked"].includes(status)) {
|
||||
if (
|
||||
status &&
|
||||
["available", "expired", "redeemed", "revoked"].includes(status)
|
||||
) {
|
||||
const clauses = {
|
||||
available: "c.redeemed_at is null and c.revoked_at is null and (c.expires_at is null or c.expires_at > now())",
|
||||
expired: "c.redeemed_at is null and c.revoked_at is null and c.expires_at <= now()",
|
||||
available:
|
||||
"c.redeemed_at is null and c.revoked_at is null and (c.expires_at is null or c.expires_at > now())",
|
||||
expired:
|
||||
"c.redeemed_at is null and c.revoked_at is null and c.expires_at <= now()",
|
||||
redeemed: "c.redeemed_at is not null",
|
||||
revoked: "c.revoked_at is not null",
|
||||
};
|
||||
@@ -80,7 +93,8 @@ export async function GET(request: Request) {
|
||||
}
|
||||
values.push(pageSize, pageOffset(page, pageSize));
|
||||
const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "c.created_at";
|
||||
const rows = await queryAdminRows<CodeRow>(`
|
||||
const rows = await queryAdminRows<CodeRow>(
|
||||
`
|
||||
select c.id, c.code_mask, c.credits, c.expires_at, c.note,
|
||||
c.created_at, c.redeemed_by, c.redeemed_email, c.redeemed_at,
|
||||
c.revoked_by, c.revoked_at,
|
||||
@@ -95,7 +109,9 @@ export async function GET(request: Request) {
|
||||
${conditions.length ? `where ${conditions.join(" and ")}` : ""}
|
||||
order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, c.id asc
|
||||
limit $${values.length - 1} offset $${values.length}
|
||||
`, values);
|
||||
`,
|
||||
values,
|
||||
);
|
||||
return NextResponse.json({
|
||||
data: rows.map(serializedCodeRow),
|
||||
total: Number(rows[0]?.total_count ?? 0),
|
||||
@@ -107,10 +123,18 @@ export async function GET(request: Request) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireAdminSession("write");
|
||||
const parsed = createCodesSchema.safeParse(await request.json().catch(() => null));
|
||||
const session = await requireHighRiskAdminMutation(
|
||||
request,
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
const parsed = createCodesSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const plainCodes = Array.from({ length: parsed.data.count }, generateRedeemCode);
|
||||
const plainCodes = Array.from(
|
||||
{ length: parsed.data.count },
|
||||
generateRedeemCode,
|
||||
);
|
||||
const records = plainCodes.map((code) => ({
|
||||
codeHash: hashRedeemCode(code),
|
||||
codeMask: maskRedeemCode(code),
|
||||
@@ -123,20 +147,23 @@ export async function POST(request: Request) {
|
||||
"admin_create_redemption_codes",
|
||||
session,
|
||||
operationRequestId,
|
||||
{ p_codes: records },
|
||||
{ p_codes: records, p_reason: parsed.data.reason },
|
||||
);
|
||||
const byMask = new Map<string, RedemptionCodeRecord>(
|
||||
stored.map((record) => [record.mask, record]),
|
||||
);
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
id: operationRequestId,
|
||||
generated: plainCodes.map((code) => ({
|
||||
...(byMask.get(maskRedeemCode(code)) ?? {}),
|
||||
code,
|
||||
})),
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
id: operationRequestId,
|
||||
generated: plainCodes.map((code) => ({
|
||||
...(byMask.get(maskRedeemCode(code)) ?? {}),
|
||||
code,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}, { status: 201 });
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user