forked from wangziqi/gongxue-base
feat: add platform subscription invoice batching
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
createInvoiceRoute,
|
||||
createSubscriptionRoute,
|
||||
createTenantInvoiceFromSubscriptionRoute,
|
||||
createTenantInvoicesBatchFromSubscriptionsRoute,
|
||||
createTenantRoute,
|
||||
platformAuditLogsRoute,
|
||||
platformOverviewRoute,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
platformQuestionBanksRoute,
|
||||
questionBankGrantsRoute,
|
||||
recordUsageRoute,
|
||||
subscriptionInvoiceCandidatesRoute,
|
||||
tenantDetailRoute,
|
||||
tenantInvoicesRoute,
|
||||
tenantsRoute,
|
||||
@@ -35,7 +37,9 @@ export const platformAdminRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
|
||||
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
|
||||
['POST', '/api/platform-admin/invoices', createInvoiceRoute],
|
||||
['GET', '/api/platform-admin/invoices/subscription-candidates', subscriptionInvoiceCandidatesRoute],
|
||||
['POST', '/api/platform-admin/invoices/from-subscription', createTenantInvoiceFromSubscriptionRoute],
|
||||
['POST', '/api/platform-admin/invoices/from-subscriptions-batch', createTenantInvoicesBatchFromSubscriptionsRoute],
|
||||
['POST', '/api/platform-admin/invoices/payments/manual-confirm', confirmInvoicePaymentRoute],
|
||||
['GET', '/api/platform-admin/usage', tenantUsageRoute],
|
||||
['POST', '/api/platform-admin/usage', recordUsageRoute],
|
||||
|
||||
@@ -39,6 +39,38 @@ function optionalUuidArray(body: Record<string, unknown>, key: string) {
|
||||
return optionalStringArray(body, key).filter(Boolean);
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']);
|
||||
|
||||
function optionalUuidList(value: unknown, key = 'ids', maxLength = 500) {
|
||||
const values = Array.isArray(value)
|
||||
? value.map(item => String(item).trim()).filter(Boolean)
|
||||
: typeof value === 'string' && value.trim()
|
||||
? value.split(',').map(item => item.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (values.length > maxLength) throw new HttpError(400, `${key} contains too many values`, 'TOO_MANY_UUIDS');
|
||||
for (const item of values) {
|
||||
if (!UUID_RE.test(item)) throw new HttpError(400, `${key} contains an invalid UUID`, 'INVALID_UUID');
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function daysAheadFrom(value: unknown, fallback = 45) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
||||
return Math.min(Math.trunc(parsed), 1095);
|
||||
}
|
||||
|
||||
function booleanFrom(value: unknown, fallback = false) {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function billingInvoiceTypeFrom(value: string) {
|
||||
const invoiceType = value || 'none';
|
||||
if (!['none', 'normal_vat', 'special_vat'].includes(invoiceType)) {
|
||||
@@ -47,6 +79,14 @@ function billingInvoiceTypeFrom(value: string) {
|
||||
return invoiceType;
|
||||
}
|
||||
|
||||
function tenantInvoiceStatusFrom(value: string) {
|
||||
const status = value || 'issued';
|
||||
if (!TENANT_INVOICE_STATUSES.has(status)) {
|
||||
throw new HttpError(400, 'invoice status is invalid', 'INVALID_INVOICE_STATUS');
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function platformAuditDetails(value: unknown) {
|
||||
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
||||
}
|
||||
@@ -939,7 +979,7 @@ interface CreateInvoiceInput {
|
||||
items: ReturnType<typeof normalizeInvoiceItems>;
|
||||
}
|
||||
|
||||
async function createInvoiceRecord(input: CreateInvoiceInput) {
|
||||
async function createInvoiceRecordWithClient(client: pg.PoolClient, input: CreateInvoiceInput) {
|
||||
if (!input.items.length) throw new HttpError(400, 'At least one invoice item is required', 'INVOICE_ITEMS_REQUIRED');
|
||||
|
||||
const discountCents = centsFrom(input.discountCents, 0);
|
||||
@@ -948,70 +988,72 @@ async function createInvoiceRecord(input: CreateInvoiceInput) {
|
||||
const totalCents = Math.max(0, subtotalCents - discountCents + taxCents);
|
||||
const invoiceNo = input.invoiceNo || createInvoiceNo();
|
||||
|
||||
return transaction(async client => {
|
||||
const invoiceResult = await client.query(
|
||||
const invoiceResult = await client.query(
|
||||
`
|
||||
insert into public.tenant_invoices (
|
||||
tenant_id, invoice_no, invoice_type, status, currency,
|
||||
subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents,
|
||||
billing_period_start, billing_period_end, due_date, issued_at, note, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9, 0, $9,
|
||||
$10::date, $11::date, $12::date,
|
||||
case when $4 = 'draft' then null else now() end,
|
||||
$13, $14::jsonb
|
||||
)
|
||||
returning id, tenant_id as "tenantId", invoice_no as "invoiceNo",
|
||||
invoice_type as "invoiceType", status, total_cents as "totalCents",
|
||||
balance_cents as "balanceCents", due_date as "dueDate",
|
||||
created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
invoiceNo,
|
||||
input.invoiceType || 'subscription',
|
||||
input.status || 'issued',
|
||||
input.currency || 'CNY',
|
||||
subtotalCents,
|
||||
discountCents,
|
||||
taxCents,
|
||||
totalCents,
|
||||
input.billingPeriodStart || null,
|
||||
input.billingPeriodEnd || null,
|
||||
input.dueDate || null,
|
||||
input.note || null,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
|
||||
const invoice = invoiceResult.rows[0];
|
||||
for (const itemInput of input.items) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_invoices (
|
||||
tenant_id, invoice_no, invoice_type, status, currency,
|
||||
subtotal_cents, discount_cents, tax_cents, total_cents, paid_cents, balance_cents,
|
||||
billing_period_start, billing_period_end, due_date, issued_at, note, metadata
|
||||
insert into public.tenant_invoice_items (
|
||||
tenant_id, invoice_id, item_type, description,
|
||||
quantity, unit_amount_cents, amount_cents, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9, 0, $9,
|
||||
$10::date, $11::date, $12::date,
|
||||
case when $4 = 'draft' then null else now() end,
|
||||
$13, $14::jsonb
|
||||
)
|
||||
returning id, tenant_id as "tenantId", invoice_no as "invoiceNo",
|
||||
invoice_type as "invoiceType", status, total_cents as "totalCents",
|
||||
balance_cents as "balanceCents", due_date as "dueDate",
|
||||
created_at as "createdAt"
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
invoiceNo,
|
||||
input.invoiceType || 'subscription',
|
||||
input.status || 'issued',
|
||||
input.currency || 'CNY',
|
||||
subtotalCents,
|
||||
discountCents,
|
||||
taxCents,
|
||||
totalCents,
|
||||
input.billingPeriodStart || null,
|
||||
input.billingPeriodEnd || null,
|
||||
input.dueDate || null,
|
||||
input.note || null,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
invoice.id,
|
||||
itemInput.itemType,
|
||||
itemInput.description,
|
||||
itemInput.quantity,
|
||||
itemInput.unitAmountCents,
|
||||
Math.round(itemInput.quantity * itemInput.unitAmountCents),
|
||||
JSON.stringify(itemInput.metadata || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const invoice = invoiceResult.rows[0];
|
||||
for (const itemInput of input.items) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_invoice_items (
|
||||
tenant_id, invoice_id, item_type, description,
|
||||
quantity, unit_amount_cents, amount_cents, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
invoice.id,
|
||||
itemInput.itemType,
|
||||
itemInput.description,
|
||||
itemInput.quantity,
|
||||
itemInput.unitAmountCents,
|
||||
Math.round(itemInput.quantity * itemInput.unitAmountCents),
|
||||
JSON.stringify(itemInput.metadata || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
await recalculateInvoiceTotals(client, invoice.id);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
await recalculateInvoiceTotals(client, invoice.id);
|
||||
return invoice;
|
||||
});
|
||||
async function createInvoiceRecord(input: CreateInvoiceInput) {
|
||||
return transaction(async client => createInvoiceRecordWithClient(client, input));
|
||||
}
|
||||
|
||||
export async function createInvoiceRoute(ctx: RequestContext) {
|
||||
@@ -1176,6 +1218,87 @@ export async function tenantUsageRoute(ctx: RequestContext) {
|
||||
return { items };
|
||||
}
|
||||
|
||||
async function subscriptionInvoiceCandidateQuery(params: {
|
||||
tenantIds: string[];
|
||||
subscriptionIds: string[];
|
||||
daysAhead: number;
|
||||
includeExisting: boolean;
|
||||
limit: number;
|
||||
}) {
|
||||
return query<{
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
tenantName: string;
|
||||
billingStatus: string;
|
||||
subscriptionId: string;
|
||||
planCode: string;
|
||||
planName: string | null;
|
||||
status: string;
|
||||
startsAt: string | null;
|
||||
expiresAt: string | null;
|
||||
billingCycle: string | null;
|
||||
amountCents: number;
|
||||
existingInvoiceId: string | null;
|
||||
existingInvoiceNo: string | null;
|
||||
existingInvoiceStatus: string | null;
|
||||
}>(
|
||||
`
|
||||
select t.id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
|
||||
t.billing_status as "billingStatus",
|
||||
s.id as "subscriptionId", s.plan_code as "planCode",
|
||||
p.name as "planName", s.status, s.starts_at as "startsAt",
|
||||
s.expires_at as "expiresAt", s.billing_cycle as "billingCycle",
|
||||
s.amount_cents as "amountCents",
|
||||
existing.id as "existingInvoiceId",
|
||||
existing.invoice_no as "existingInvoiceNo",
|
||||
existing.status as "existingInvoiceStatus"
|
||||
from public.tenant_subscriptions s
|
||||
join public.tenants t on t.id = s.tenant_id
|
||||
left join public.platform_saas_plans p on p.code = s.plan_code
|
||||
left join lateral (
|
||||
select id, invoice_no, status
|
||||
from public.tenant_invoices i
|
||||
where i.tenant_id = s.tenant_id
|
||||
and i.invoice_type = 'subscription'
|
||||
and i.status <> 'void'
|
||||
and i.metadata->>'subscriptionId' = s.id::text
|
||||
order by i.created_at desc
|
||||
limit 1
|
||||
) existing on true
|
||||
where t.status = 'active'
|
||||
and s.status in ('trial', 'active', 'past_due')
|
||||
and ($1::uuid[] = '{}'::uuid[] or s.tenant_id = any($1::uuid[]))
|
||||
and ($2::uuid[] = '{}'::uuid[] or s.id = any($2::uuid[]))
|
||||
and (
|
||||
s.expires_at is null
|
||||
or s.expires_at <= now() + ($3::integer * interval '1 day')
|
||||
)
|
||||
and ($4::boolean = true or existing.id is null)
|
||||
order by s.expires_at asc nulls last, t.created_at asc
|
||||
limit $5
|
||||
`,
|
||||
[params.tenantIds, params.subscriptionIds, params.daysAhead, params.includeExisting, params.limit],
|
||||
);
|
||||
}
|
||||
|
||||
export async function subscriptionInvoiceCandidatesRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const tenantIds = optionalUuidList(ctx.url.searchParams.get('tenantIds'), 'tenantIds');
|
||||
const subscriptionIds = optionalUuidList(ctx.url.searchParams.get('subscriptionIds'), 'subscriptionIds');
|
||||
const daysAhead = daysAheadFrom(ctx.url.searchParams.get('daysAhead'), 45);
|
||||
const includeExisting = listQuery(ctx, 'includeExisting') === 'true';
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const items = await subscriptionInvoiceCandidateQuery({ tenantIds, subscriptionIds, daysAhead, includeExisting, limit });
|
||||
|
||||
return {
|
||||
items: items.map(item => ({
|
||||
...item,
|
||||
hasExistingInvoice: Boolean(item.existingInvoiceId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
@@ -1183,54 +1306,217 @@ export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestConte
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
const subscriptionId = optionalString(body, 'subscriptionId');
|
||||
|
||||
const subscription = await queryOne<{
|
||||
id: string;
|
||||
planCode: string;
|
||||
amountCents: number;
|
||||
startsAt: string | null;
|
||||
expiresAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, plan_code as "planCode", amount_cents as "amountCents",
|
||||
starts_at as "startsAt", expires_at as "expiresAt"
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = $1
|
||||
and ($2::uuid is null or id = $2::uuid)
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, subscriptionId || null],
|
||||
);
|
||||
if (!subscription) throw new HttpError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND');
|
||||
const item = await transaction(async client => {
|
||||
const subscriptionResult = await client.query<{
|
||||
id: string;
|
||||
planCode: string;
|
||||
amountCents: number;
|
||||
startsAt: string | null;
|
||||
expiresAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, plan_code as "planCode", amount_cents as "amountCents",
|
||||
starts_at as "startsAt", expires_at as "expiresAt"
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = $1
|
||||
and ($2::uuid is null or id = $2::uuid)
|
||||
order by created_at desc
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, subscriptionId || null],
|
||||
);
|
||||
const subscription = subscriptionResult.rows[0];
|
||||
if (!subscription) throw new HttpError(404, 'Subscription not found', 'SUBSCRIPTION_NOT_FOUND');
|
||||
|
||||
const plan = await queryOne<{ name: string }>(
|
||||
`
|
||||
select name from public.platform_saas_plans
|
||||
where code = $1
|
||||
limit 1
|
||||
`,
|
||||
[subscription.planCode],
|
||||
);
|
||||
const planResult = await client.query<{ name: string }>(
|
||||
`
|
||||
select name from public.platform_saas_plans
|
||||
where code = $1
|
||||
limit 1
|
||||
`,
|
||||
[subscription.planCode],
|
||||
);
|
||||
const plan = planResult.rows[0];
|
||||
|
||||
const item = await createInvoiceRecord({
|
||||
tenantId,
|
||||
invoiceType: 'subscription',
|
||||
status: optionalString(body, 'status') || 'issued',
|
||||
dueDate: optionalString(body, 'dueDate') || null,
|
||||
billingPeriodStart: toDateText(subscription.startsAt),
|
||||
billingPeriodEnd: toDateText(subscription.expiresAt),
|
||||
note: optionalString(body, 'note') || null,
|
||||
metadata: { source: 'subscription', subscriptionId: subscription.id },
|
||||
items: [
|
||||
{
|
||||
itemType: 'subscription',
|
||||
description: `${plan?.name || subscription.planCode} ${subscription.planCode}`,
|
||||
quantity: 1,
|
||||
unitAmountCents: subscription.amountCents,
|
||||
metadata: { subscriptionId: subscription.id, planCode: subscription.planCode },
|
||||
},
|
||||
],
|
||||
const existing = await client.query<{ id: string; invoiceNo: string; status: string }>(
|
||||
`
|
||||
select id, invoice_no as "invoiceNo", status
|
||||
from public.tenant_invoices
|
||||
where tenant_id = $1
|
||||
and invoice_type = 'subscription'
|
||||
and status <> 'void'
|
||||
and metadata->>'subscriptionId' = $2
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, subscription.id],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
throw new HttpError(409, 'Invoice already exists for this subscription', 'SUBSCRIPTION_INVOICE_EXISTS');
|
||||
}
|
||||
|
||||
const invoice = await createInvoiceRecordWithClient(client, {
|
||||
tenantId,
|
||||
invoiceType: 'subscription',
|
||||
status: tenantInvoiceStatusFrom(optionalString(body, 'status')),
|
||||
dueDate: optionalString(body, 'dueDate') || null,
|
||||
billingPeriodStart: toDateText(subscription.startsAt),
|
||||
billingPeriodEnd: toDateText(subscription.expiresAt),
|
||||
note: optionalString(body, 'note') || null,
|
||||
metadata: { source: 'subscription', subscriptionId: subscription.id },
|
||||
items: [
|
||||
{
|
||||
itemType: 'subscription',
|
||||
description: `${plan?.name || subscription.planCode} ${subscription.planCode}`,
|
||||
quantity: 1,
|
||||
unitAmountCents: subscription.amountCents,
|
||||
metadata: { subscriptionId: subscription.id, planCode: subscription.planCode },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await recordPlatformAudit(client, ctx, 'platform.invoice.subscription_created', 'tenant_invoice', invoice.id, {
|
||||
tenantId,
|
||||
subscriptionId: subscription.id,
|
||||
planCode: subscription.planCode,
|
||||
invoiceNo: invoice.invoiceNo,
|
||||
source: 'single',
|
||||
}, tenantId);
|
||||
return invoice;
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function createTenantInvoicesBatchFromSubscriptionsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds');
|
||||
const requestedSubscriptionIds = optionalUuidList(body.subscriptionIds, 'subscriptionIds');
|
||||
const daysAhead = daysAheadFrom(body.daysAhead, 45);
|
||||
const status = tenantInvoiceStatusFrom(optionalString(body, 'status'));
|
||||
const dueDate = optionalString(body, 'dueDate') || null;
|
||||
const note = optionalString(body, 'note') || null;
|
||||
const dryRun = booleanFrom(body.dryRun, false);
|
||||
const limit = Math.min(Math.max(requestedSubscriptionIds.length || 0, 100), 500);
|
||||
|
||||
const candidates = await subscriptionInvoiceCandidateQuery({
|
||||
tenantIds,
|
||||
subscriptionIds: requestedSubscriptionIds,
|
||||
daysAhead,
|
||||
includeExisting: false,
|
||||
limit,
|
||||
});
|
||||
const scopedCandidates = candidates;
|
||||
|
||||
if (!scopedCandidates.length) {
|
||||
return { item: { dryRun, createdCount: 0, skippedCount: 0, items: [] } };
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
item: {
|
||||
dryRun: true,
|
||||
createdCount: 0,
|
||||
skippedCount: 0,
|
||||
items: scopedCandidates.map(item => ({
|
||||
...item,
|
||||
hasExistingInvoice: false,
|
||||
wouldCreate: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const created: unknown[] = [];
|
||||
const skipped: unknown[] = [];
|
||||
|
||||
for (const candidate of scopedCandidates) {
|
||||
const lock = await client.query(
|
||||
`
|
||||
select id
|
||||
from public.tenant_subscriptions
|
||||
where id = $1
|
||||
for update
|
||||
`,
|
||||
[candidate.subscriptionId],
|
||||
);
|
||||
if (!lock.rows[0]) {
|
||||
skipped.push({ subscriptionId: candidate.subscriptionId, reason: 'SUBSCRIPTION_NOT_FOUND' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await client.query<{ id: string; invoiceNo: string; status: string }>(
|
||||
`
|
||||
select id, invoice_no as "invoiceNo", status
|
||||
from public.tenant_invoices
|
||||
where tenant_id = $1
|
||||
and invoice_type = 'subscription'
|
||||
and status <> 'void'
|
||||
and metadata->>'subscriptionId' = $2
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[candidate.tenantId, candidate.subscriptionId],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
skipped.push({
|
||||
tenantId: candidate.tenantId,
|
||||
subscriptionId: candidate.subscriptionId,
|
||||
reason: 'SUBSCRIPTION_INVOICE_EXISTS',
|
||||
invoiceId: existing.rows[0].id,
|
||||
invoiceNo: existing.rows[0].invoiceNo,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const invoice = await createInvoiceRecordWithClient(client, {
|
||||
tenantId: candidate.tenantId,
|
||||
invoiceType: 'subscription',
|
||||
status,
|
||||
dueDate,
|
||||
billingPeriodStart: toDateText(candidate.startsAt),
|
||||
billingPeriodEnd: toDateText(candidate.expiresAt),
|
||||
note,
|
||||
metadata: {
|
||||
source: 'subscription_batch',
|
||||
subscriptionId: candidate.subscriptionId,
|
||||
tenantSlug: candidate.tenantSlug,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
itemType: 'subscription',
|
||||
description: `${candidate.planName || candidate.planCode} ${candidate.planCode}`,
|
||||
quantity: 1,
|
||||
unitAmountCents: Number(candidate.amountCents || 0),
|
||||
metadata: { subscriptionId: candidate.subscriptionId, planCode: candidate.planCode },
|
||||
},
|
||||
],
|
||||
});
|
||||
created.push({ ...candidate, invoice });
|
||||
}
|
||||
|
||||
await recordPlatformAudit(client, ctx, 'platform.invoice.subscription_batch_created', 'tenant_invoice_batch', null, {
|
||||
createdCount: created.length,
|
||||
skippedCount: skipped.length,
|
||||
tenantIds,
|
||||
subscriptionIds: requestedSubscriptionIds,
|
||||
daysAhead,
|
||||
dueDate,
|
||||
status,
|
||||
});
|
||||
|
||||
return {
|
||||
dryRun: false,
|
||||
createdCount: created.length,
|
||||
skippedCount: skipped.length,
|
||||
items: created,
|
||||
skipped,
|
||||
};
|
||||
});
|
||||
|
||||
return { item: result };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user