feat: add invoice URL to proration and test seed scripts (#27297)
* feat: add invoice URL to proration and test seed scripts - Add invoiceUrl field to MonthlyProration schema - Update billing service to return hosted_invoice_url after finalizing - Save invoice URL when creating proration invoices - Add seed script for testing proration with real Stripe data - Add cleanup script for test data removal Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * add invoice url * add invoice URL to tests --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
8319666d76
commit
ea76df47d2
@@ -47,6 +47,7 @@ export class MonthlyProrationRepository {
|
||||
additionalData?: {
|
||||
invoiceItemId?: string;
|
||||
invoiceId?: string;
|
||||
invoiceUrl?: string;
|
||||
chargedAt?: Date;
|
||||
failedAt?: Date;
|
||||
failureReason?: string;
|
||||
|
||||
@@ -90,7 +90,7 @@ export interface IBillingProviderService {
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{ invoiceId: string }>;
|
||||
|
||||
finalizeInvoice(invoiceId: string): Promise<void>;
|
||||
finalizeInvoice(invoiceId: string): Promise<{ invoiceUrl: string | null }>;
|
||||
|
||||
voidInvoice(invoiceId: string): Promise<void>;
|
||||
|
||||
|
||||
@@ -292,7 +292,8 @@ export class StripeBillingService implements IBillingProviderService {
|
||||
}
|
||||
|
||||
async finalizeInvoice(invoiceId: string) {
|
||||
await this.stripe.invoices.finalizeInvoice(invoiceId);
|
||||
const invoice = await this.stripe.invoices.finalizeInvoice(invoiceId);
|
||||
return { invoiceUrl: invoice.hosted_invoice_url ?? null };
|
||||
}
|
||||
|
||||
async voidInvoice(invoiceId: string) {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Cleanup script for proration test data
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx packages/features/ee/billing/service/dueInvoice/cleanup-proration-test.ts
|
||||
*
|
||||
* Options:
|
||||
* --skip-stripe Skip Stripe API cleanup
|
||||
*/
|
||||
|
||||
import { config } from "dotenv";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// Load environment variables from .env file
|
||||
config({ path: resolve(process.cwd(), ".env") });
|
||||
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const SKIP_STRIPE = process.argv.includes("--skip-stripe");
|
||||
|
||||
function getStripeClient(): Stripe | null {
|
||||
if (SKIP_STRIPE) {
|
||||
console.log("Skipping Stripe cleanup (--skip-stripe flag)");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!process.env.STRIPE_PRIVATE_KEY) {
|
||||
console.log("STRIPE_PRIVATE_KEY not set, skipping Stripe cleanup");
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Stripe(process.env.STRIPE_PRIVATE_KEY, {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupStripeResources(stripe: Stripe) {
|
||||
console.log("\nCleaning up Stripe resources...");
|
||||
|
||||
// Find and delete test customers by email
|
||||
const customers = await stripe.customers.list({
|
||||
limit: 100,
|
||||
email: "proration-admin@example.com",
|
||||
});
|
||||
|
||||
for (const customer of customers.data) {
|
||||
try {
|
||||
// Cancel all subscriptions first
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: customer.id,
|
||||
status: "all",
|
||||
});
|
||||
|
||||
for (const sub of subscriptions.data) {
|
||||
if (sub.status !== "canceled") {
|
||||
await stripe.subscriptions.cancel(sub.id);
|
||||
console.log(` Cancelled subscription: ${sub.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Void any open invoices
|
||||
const invoices = await stripe.invoices.list({
|
||||
customer: customer.id,
|
||||
status: "open",
|
||||
});
|
||||
|
||||
for (const invoice of invoices.data) {
|
||||
try {
|
||||
await stripe.invoices.voidInvoice(invoice.id);
|
||||
console.log(` Voided invoice: ${invoice.id}`);
|
||||
} catch {
|
||||
console.log(` Could not void invoice ${invoice.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the customer
|
||||
await stripe.customers.del(customer.id);
|
||||
console.log(` Deleted customer: ${customer.id}`);
|
||||
} catch (error) {
|
||||
console.log(` Error cleaning up customer ${customer.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Archive test products
|
||||
const products = await stripe.products.list({ limit: 100 });
|
||||
|
||||
for (const product of products.data) {
|
||||
if (product.name.startsWith("Proration Test") && product.active) {
|
||||
try {
|
||||
await stripe.products.update(product.id, { active: false });
|
||||
console.log(` Archived product: ${product.id}`);
|
||||
} catch {
|
||||
console.log(` Could not archive product ${product.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Stripe cleanup complete");
|
||||
}
|
||||
|
||||
async function cleanupDatabaseResources() {
|
||||
console.log("\nCleaning up database resources...");
|
||||
|
||||
// Find test organization
|
||||
const org = await prisma.team.findFirst({
|
||||
where: { slug: "proration-test-org" },
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
console.log(" No test organization found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` Found test organization: ${org.name} (ID: ${org.id})`);
|
||||
|
||||
// Delete proration records
|
||||
const deletedProrations = await prisma.monthlyProration.deleteMany({
|
||||
where: { teamId: org.id },
|
||||
});
|
||||
console.log(` Deleted ${deletedProrations.count} proration records`);
|
||||
|
||||
// Delete seat change logs
|
||||
const deletedLogs = await prisma.seatChangeLog.deleteMany({
|
||||
where: { teamId: org.id },
|
||||
});
|
||||
console.log(` Deleted ${deletedLogs.count} seat change logs`);
|
||||
|
||||
// Delete organization billing
|
||||
await prisma.organizationBilling
|
||||
.delete({ where: { teamId: org.id } })
|
||||
.catch(() => console.log(" No organization billing to delete"));
|
||||
|
||||
// Delete organization settings
|
||||
await prisma.organizationSettings
|
||||
.delete({ where: { organizationId: org.id } })
|
||||
.catch(() => console.log(" No organization settings to delete"));
|
||||
|
||||
// Delete profiles for org members
|
||||
await prisma.profile.deleteMany({ where: { organizationId: org.id } });
|
||||
console.log(" Deleted profiles");
|
||||
|
||||
// Find and delete child teams
|
||||
const childTeams = await prisma.team.findMany({
|
||||
where: { parentId: org.id },
|
||||
});
|
||||
|
||||
for (const team of childTeams) {
|
||||
await prisma.membership.deleteMany({ where: { teamId: team.id } });
|
||||
await prisma.team.delete({ where: { id: team.id } });
|
||||
console.log(` Deleted team: ${team.name}`);
|
||||
}
|
||||
|
||||
// Delete org memberships
|
||||
const deletedMemberships = await prisma.membership.deleteMany({
|
||||
where: { teamId: org.id },
|
||||
});
|
||||
console.log(` Deleted ${deletedMemberships.count} memberships`);
|
||||
|
||||
// Delete organization
|
||||
await prisma.team.delete({ where: { id: org.id } });
|
||||
console.log(` Deleted organization: ${org.name}`);
|
||||
|
||||
// Delete test users (admin, member, and additional users 1-6)
|
||||
const testEmails = [
|
||||
"proration-admin@example.com",
|
||||
"proration-member@example.com",
|
||||
...Array.from({ length: 6 }, (_, i) => `proration-user-${i + 1}@example.com`),
|
||||
];
|
||||
for (const email of testEmails) {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (user) {
|
||||
await prisma.password.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.membership.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
console.log(` Deleted user: ${email}`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Database cleanup complete");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== Proration Test Cleanup Script ===");
|
||||
console.log("\nOptions:");
|
||||
console.log(` --skip-stripe: ${SKIP_STRIPE}`);
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
try {
|
||||
await cleanupDatabaseResources();
|
||||
|
||||
if (stripe) {
|
||||
await cleanupStripeResources(stripe);
|
||||
}
|
||||
|
||||
console.log("\n=== Cleanup Complete ===");
|
||||
} catch (error) {
|
||||
console.error("\nCleanup failed:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,745 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Seed script for testing due invoice banner and invitation blocking
|
||||
*
|
||||
* This script creates:
|
||||
* 1. A test organization with billing data
|
||||
* 2. A test team under the organization
|
||||
* 3. Test users with billing permissions
|
||||
* 4. Real Stripe customers, subscriptions, and invoices
|
||||
* 5. MonthlyProration records to simulate overdue invoices
|
||||
*
|
||||
* Prerequisites:
|
||||
* - STRIPE_PRIVATE_KEY must be set (use test mode key)
|
||||
* - STRIPE_ORG_MONTHLY_PRICE_ID or STRIPE_TEAM_MONTHLY_PRICE_ID should be set
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts
|
||||
*
|
||||
* Options:
|
||||
* --skip-stripe Skip Stripe API calls (use fake IDs)
|
||||
* --cleanup Clean up test data before seeding
|
||||
*/
|
||||
|
||||
import { config } from "dotenv";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// Load environment variables from .env file
|
||||
config({ path: resolve(process.cwd(), ".env") });
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "password123";
|
||||
const SKIP_STRIPE = process.argv.includes("--skip-stripe");
|
||||
const CLEANUP_FIRST = process.argv.includes("--cleanup");
|
||||
|
||||
interface StripeResources {
|
||||
customer: Stripe.Customer | null;
|
||||
subscription: Stripe.Subscription | null;
|
||||
product: Stripe.Product | null;
|
||||
price: Stripe.Price | null;
|
||||
}
|
||||
|
||||
interface SeedResult {
|
||||
organization: { id: number; name: string; slug: string };
|
||||
team: { id: number; name: string; slug: string };
|
||||
users: Array<{ email: string; name: string; role: string }>;
|
||||
prorations: Array<{ id: string; status: string; isBlocking: boolean }>;
|
||||
stripe: StripeResources;
|
||||
}
|
||||
|
||||
function getStripeClient(): Stripe | null {
|
||||
if (SKIP_STRIPE) {
|
||||
console.log("Skipping Stripe API calls (--skip-stripe flag)");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!process.env.STRIPE_PRIVATE_KEY) {
|
||||
console.log("STRIPE_PRIVATE_KEY not set, skipping Stripe API calls");
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Stripe(process.env.STRIPE_PRIVATE_KEY, {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestUser(email: string, name: string, username: string, organizationId?: number) {
|
||||
const hashedPassword = await hashPassword(TEST_PASSWORD);
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {
|
||||
completedOnboarding: true,
|
||||
organizationId,
|
||||
},
|
||||
create: {
|
||||
email,
|
||||
name,
|
||||
username,
|
||||
password: { create: { hash: hashedPassword } },
|
||||
emailVerified: new Date(),
|
||||
completedOnboarding: true,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
// Create a Profile for the user in the organization if organizationId is set
|
||||
if (organizationId) {
|
||||
await prisma.profile.upsert({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
uid: `${user.id}-${organizationId}`,
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
username,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function cleanupStripeResources(stripe: Stripe) {
|
||||
console.log("Cleaning up existing Stripe test resources...");
|
||||
|
||||
// Find and delete test customers
|
||||
const customers = await stripe.customers.list({
|
||||
limit: 100,
|
||||
email: "proration-admin@example.com",
|
||||
});
|
||||
|
||||
for (const customer of customers.data) {
|
||||
try {
|
||||
// Cancel subscriptions first
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: customer.id,
|
||||
status: "all",
|
||||
});
|
||||
for (const sub of subscriptions.data) {
|
||||
if (sub.status !== "canceled") {
|
||||
await stripe.subscriptions.cancel(sub.id);
|
||||
console.log(` Cancelled subscription: ${sub.id}`);
|
||||
}
|
||||
}
|
||||
// Delete customer
|
||||
await stripe.customers.del(customer.id);
|
||||
console.log(` Deleted customer: ${customer.id}`);
|
||||
} catch (error) {
|
||||
console.log(` Could not delete customer ${customer.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Find and archive test products
|
||||
const products = await stripe.products.list({
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
for (const product of products.data) {
|
||||
if (product.name.startsWith("Proration Test") && product.active) {
|
||||
await stripe.products.update(product.id, { active: false });
|
||||
console.log(` Archived product: ${product.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createStripeResources(stripe: Stripe, orgId: number): Promise<StripeResources> {
|
||||
console.log("Creating Stripe resources...");
|
||||
|
||||
// Create a test product
|
||||
const product = await stripe.products.create({
|
||||
name: `Proration Test Product - ${Date.now()}`,
|
||||
description: "Test product for proration testing",
|
||||
metadata: {
|
||||
testData: "true",
|
||||
orgId: orgId.toString(),
|
||||
},
|
||||
});
|
||||
console.log(` Created product: ${product.id}`);
|
||||
|
||||
// Create a price for the product ($15/seat/month)
|
||||
const price = await stripe.prices.create({
|
||||
product: product.id,
|
||||
unit_amount: 1500, // $15.00
|
||||
currency: "usd",
|
||||
recurring: {
|
||||
interval: "month",
|
||||
usage_type: "licensed",
|
||||
},
|
||||
metadata: {
|
||||
testData: "true",
|
||||
},
|
||||
});
|
||||
console.log(` Created price: ${price.id}`);
|
||||
|
||||
// Create a customer
|
||||
const customer = await stripe.customers.create({
|
||||
email: "proration-admin@example.com",
|
||||
name: "Proration Test Org",
|
||||
metadata: {
|
||||
testData: "true",
|
||||
orgId: orgId.toString(),
|
||||
calOrgId: orgId.toString(),
|
||||
},
|
||||
});
|
||||
console.log(` Created customer: ${customer.id}`);
|
||||
|
||||
// Add a test payment method (test card)
|
||||
const paymentMethod = await stripe.paymentMethods.create({
|
||||
type: "card",
|
||||
card: {
|
||||
token: "tok_visa", // Test token for successful payments
|
||||
},
|
||||
});
|
||||
|
||||
await stripe.paymentMethods.attach(paymentMethod.id, {
|
||||
customer: customer.id,
|
||||
});
|
||||
|
||||
await stripe.customers.update(customer.id, {
|
||||
invoice_settings: {
|
||||
default_payment_method: paymentMethod.id,
|
||||
},
|
||||
});
|
||||
console.log(` Attached payment method: ${paymentMethod.id}`);
|
||||
|
||||
// Create a subscription with 5 seats
|
||||
const subscription = await stripe.subscriptions.create({
|
||||
customer: customer.id,
|
||||
items: [
|
||||
{
|
||||
price: price.id,
|
||||
quantity: 5,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
testData: "true",
|
||||
orgId: orgId.toString(),
|
||||
},
|
||||
});
|
||||
console.log(` Created subscription: ${subscription.id}`);
|
||||
|
||||
return { customer, subscription, product, price };
|
||||
}
|
||||
|
||||
async function createFailedInvoice(
|
||||
stripe: Stripe,
|
||||
customerId: string,
|
||||
amount: number,
|
||||
description: string
|
||||
): Promise<Stripe.Invoice> {
|
||||
console.log(`Creating invoice for $${(amount / 100).toFixed(2)}...`);
|
||||
|
||||
// Create an invoice item
|
||||
await stripe.invoiceItems.create({
|
||||
customer: customerId,
|
||||
amount,
|
||||
currency: "usd",
|
||||
description,
|
||||
});
|
||||
|
||||
// Create and finalize the invoice
|
||||
const invoice = await stripe.invoices.create({
|
||||
customer: customerId,
|
||||
auto_advance: false, // Don't auto-charge
|
||||
metadata: {
|
||||
testData: "true",
|
||||
prorationTest: "true",
|
||||
},
|
||||
});
|
||||
|
||||
const finalizedInvoice = await stripe.invoices.finalizeInvoice(invoice.id);
|
||||
console.log(` Created invoice: ${finalizedInvoice.id} (status: ${finalizedInvoice.status})`);
|
||||
|
||||
return finalizedInvoice;
|
||||
}
|
||||
|
||||
async function cleanupDatabaseResources() {
|
||||
console.log("Cleaning up database test resources...");
|
||||
|
||||
const org = await prisma.team.findFirst({
|
||||
where: { slug: "proration-test-org" },
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
console.log(" No test organization found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete proration records
|
||||
await prisma.monthlyProration.deleteMany({ where: { teamId: org.id } });
|
||||
|
||||
// Delete seat change logs
|
||||
await prisma.seatChangeLog.deleteMany({ where: { teamId: org.id } });
|
||||
|
||||
// Delete organization billing
|
||||
await prisma.organizationBilling.deleteMany({ where: { teamId: org.id } });
|
||||
|
||||
// Find and delete child teams
|
||||
const childTeams = await prisma.team.findMany({ where: { parentId: org.id } });
|
||||
for (const team of childTeams) {
|
||||
await prisma.membership.deleteMany({ where: { teamId: team.id } });
|
||||
await prisma.team.delete({ where: { id: team.id } });
|
||||
}
|
||||
|
||||
// Delete org memberships
|
||||
await prisma.membership.deleteMany({ where: { teamId: org.id } });
|
||||
|
||||
// Delete organization
|
||||
await prisma.team.delete({ where: { id: org.id } });
|
||||
|
||||
// Delete test users
|
||||
const testEmails = ["proration-admin@example.com", "proration-member@example.com"];
|
||||
for (const email of testEmails) {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (user) {
|
||||
await prisma.password.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.membership.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
console.log(" Database cleanup complete");
|
||||
}
|
||||
|
||||
async function seedProrationTest(): Promise<SeedResult> {
|
||||
const stripe = getStripeClient();
|
||||
|
||||
if (CLEANUP_FIRST) {
|
||||
await cleanupDatabaseResources();
|
||||
if (stripe) {
|
||||
await cleanupStripeResources(stripe);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nCreating test organization...");
|
||||
|
||||
// Create organization - find existing or create new
|
||||
let org = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "proration-test-org",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
org = await prisma.team.create({
|
||||
data: {
|
||||
name: "Proration Test Org",
|
||||
slug: "proration-test-org",
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Organization created: ${org.name} (ID: ${org.id})`);
|
||||
|
||||
// Create OrganizationSettings for the org
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: { organizationId: org.id },
|
||||
update: {},
|
||||
create: {
|
||||
organizationId: org.id,
|
||||
orgAutoAcceptEmail: "example.com",
|
||||
isOrganizationConfigured: true,
|
||||
isOrganizationVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Organization settings created");
|
||||
|
||||
// Create team under organization - find existing or create new
|
||||
let team = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: "proration-test-team",
|
||||
parentId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
team = await prisma.team.create({
|
||||
data: {
|
||||
name: "Proration Test Team",
|
||||
slug: "proration-test-team",
|
||||
parentId: org.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Team created: ${team.name} (ID: ${team.id})`);
|
||||
|
||||
// Create test users (with organizationId set to link them to the org)
|
||||
console.log("Creating test users...");
|
||||
|
||||
const adminUser = await createTestUser(
|
||||
"proration-admin@example.com",
|
||||
"Proration Admin",
|
||||
"proration-admin",
|
||||
org.id
|
||||
);
|
||||
|
||||
const memberUser = await createTestUser(
|
||||
"proration-member@example.com",
|
||||
"Proration Member",
|
||||
"proration-member",
|
||||
org.id
|
||||
);
|
||||
|
||||
// Create additional org members to simulate realistic seat count
|
||||
const additionalMembers = [];
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const user = await createTestUser(
|
||||
`proration-user-${i}@example.com`,
|
||||
`Proration User ${i}`,
|
||||
`proration-user-${i}`,
|
||||
org.id
|
||||
);
|
||||
additionalMembers.push(user);
|
||||
}
|
||||
|
||||
// Add admin to organization as OWNER
|
||||
await prisma.membership.upsert({
|
||||
where: { userId_teamId: { userId: adminUser.id, teamId: org.id } },
|
||||
update: { role: MembershipRole.OWNER },
|
||||
create: {
|
||||
userId: adminUser.id,
|
||||
teamId: org.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Add member to organization
|
||||
await prisma.membership.upsert({
|
||||
where: { userId_teamId: { userId: memberUser.id, teamId: org.id } },
|
||||
update: { role: MembershipRole.MEMBER },
|
||||
create: {
|
||||
userId: memberUser.id,
|
||||
teamId: org.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Add additional members to organization (simulating 8 total members: admin + member + 6 additional)
|
||||
for (const user of additionalMembers) {
|
||||
await prisma.membership.upsert({
|
||||
where: { userId_teamId: { userId: user.id, teamId: org.id } },
|
||||
update: { role: MembershipRole.MEMBER },
|
||||
create: {
|
||||
userId: user.id,
|
||||
teamId: org.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add admin to team
|
||||
await prisma.membership.upsert({
|
||||
where: { userId_teamId: { userId: adminUser.id, teamId: team.id } },
|
||||
update: { role: MembershipRole.OWNER },
|
||||
create: {
|
||||
userId: adminUser.id,
|
||||
teamId: team.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Add some members to the sub-team as well
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await prisma.membership.upsert({
|
||||
where: { userId_teamId: { userId: additionalMembers[i].id, teamId: team.id } },
|
||||
update: { role: MembershipRole.MEMBER },
|
||||
create: {
|
||||
userId: additionalMembers[i].id,
|
||||
teamId: team.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Users added to organization and team (8 org members, 4 team members)");
|
||||
|
||||
// Create Stripe resources or use fake IDs
|
||||
let stripeResources: StripeResources = {
|
||||
customer: null,
|
||||
subscription: null,
|
||||
product: null,
|
||||
price: null,
|
||||
};
|
||||
|
||||
let stripeCustomerId = `cus_fake_${Date.now()}`;
|
||||
let stripeSubscriptionId = `sub_fake_${Date.now()}`;
|
||||
let stripeSubscriptionItemId = `si_fake_${Date.now()}`;
|
||||
|
||||
if (stripe) {
|
||||
stripeResources = await createStripeResources(stripe, org.id);
|
||||
stripeCustomerId = stripeResources.customer!.id;
|
||||
stripeSubscriptionId = stripeResources.subscription!.id;
|
||||
stripeSubscriptionItemId = stripeResources.subscription!.items.data[0].id;
|
||||
|
||||
// Create a separate invoice that will remain unpaid (simulating failed proration)
|
||||
await createFailedInvoice(stripe, stripeCustomerId, 3000, "Proration charge - 3 additional seats");
|
||||
}
|
||||
|
||||
// Create OrganizationBilling record
|
||||
await prisma.organizationBilling.upsert({
|
||||
where: { teamId: org.id },
|
||||
update: {
|
||||
customerId: stripeCustomerId,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
subscriptionItemId: stripeSubscriptionItemId,
|
||||
},
|
||||
create: {
|
||||
teamId: org.id,
|
||||
customerId: stripeCustomerId,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
subscriptionItemId: stripeSubscriptionItemId,
|
||||
status: "ACTIVE",
|
||||
planName: "ORGANIZATION",
|
||||
subscriptionStart: new Date(),
|
||||
pricePerSeat: 1500,
|
||||
paidSeats: 5,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Organization billing created");
|
||||
|
||||
// Get the organization billing record for linking seat changes
|
||||
const orgBilling = await prisma.organizationBilling.findUnique({
|
||||
where: { teamId: org.id },
|
||||
});
|
||||
|
||||
// Create test MonthlyProration records
|
||||
console.log("Creating test proration and seat change records...");
|
||||
|
||||
const now = new Date();
|
||||
const eightDaysAgo = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000);
|
||||
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Clear existing prorations and seat change logs for this team
|
||||
await prisma.seatChangeLog.deleteMany({ where: { teamId: org.id } });
|
||||
await prisma.monthlyProration.deleteMany({ where: { teamId: org.id } });
|
||||
|
||||
// Blocking proration (8 days old, FAILED status)
|
||||
const blockingProration = await prisma.monthlyProration.create({
|
||||
data: {
|
||||
teamId: org.id,
|
||||
monthKey: "2025-01",
|
||||
periodStart: new Date("2025-01-01"),
|
||||
periodEnd: new Date("2025-01-31"),
|
||||
seatsAtStart: 5,
|
||||
seatsAdded: 3,
|
||||
seatsRemoved: 0,
|
||||
netSeatIncrease: 3,
|
||||
seatsAtEnd: 8,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
subscriptionItemId: stripeSubscriptionItemId,
|
||||
customerId: stripeCustomerId,
|
||||
subscriptionStart: new Date("2025-01-01"),
|
||||
subscriptionEnd: new Date("2025-12-31"),
|
||||
remainingDays: 20,
|
||||
pricePerSeat: 1500,
|
||||
proratedAmount: 3000, // $30.00
|
||||
status: "FAILED",
|
||||
createdAt: eightDaysAgo,
|
||||
failedAt: eightDaysAgo,
|
||||
failureReason: "card_declined",
|
||||
},
|
||||
});
|
||||
|
||||
// Warning proration (2 days old, INVOICE_CREATED status) - use different monthKey
|
||||
const warningProration = await prisma.monthlyProration.create({
|
||||
data: {
|
||||
teamId: org.id,
|
||||
monthKey: "2025-02",
|
||||
periodStart: new Date("2025-02-01"),
|
||||
periodEnd: new Date("2025-02-28"),
|
||||
seatsAtStart: 8,
|
||||
seatsAdded: 2,
|
||||
seatsRemoved: 0,
|
||||
netSeatIncrease: 2,
|
||||
seatsAtEnd: 10,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
subscriptionItemId: `${stripeSubscriptionItemId}_2`,
|
||||
customerId: stripeCustomerId,
|
||||
subscriptionStart: new Date("2025-01-01"),
|
||||
subscriptionEnd: new Date("2025-12-31"),
|
||||
remainingDays: 15,
|
||||
pricePerSeat: 1500,
|
||||
proratedAmount: 1500, // $15.00
|
||||
status: "INVOICE_CREATED",
|
||||
createdAt: twoDaysAgo,
|
||||
},
|
||||
});
|
||||
|
||||
// Create SeatChangeLog entries that correspond to the prorations
|
||||
console.log("Creating seat change logs...");
|
||||
|
||||
// Initial 5 seats (original team members) - these would have been added when org was created
|
||||
const initialAdditionDate = new Date(eightDaysAgo.getTime() - 30 * 24 * 60 * 60 * 1000); // 38 days ago
|
||||
|
||||
// Create seat addition logs for initial members (admin + member = 2, then 3 more = 5 total initial)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await prisma.seatChangeLog.create({
|
||||
data: {
|
||||
teamId: org.id,
|
||||
changeType: "ADDITION",
|
||||
seatCount: 1,
|
||||
userId: i === 0 ? adminUser.id : i === 1 ? memberUser.id : additionalMembers[i - 2].id,
|
||||
triggeredBy: adminUser.id,
|
||||
changeDate: initialAdditionDate,
|
||||
monthKey: "2024-12",
|
||||
organizationBillingId: orgBilling?.id,
|
||||
metadata: { source: "seed-script", note: "Initial org member" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Seat additions that triggered the FAILED proration (3 seats added 8 days ago)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await prisma.seatChangeLog.create({
|
||||
data: {
|
||||
teamId: org.id,
|
||||
changeType: "ADDITION",
|
||||
seatCount: 1,
|
||||
userId: additionalMembers[3 + i].id, // Users 4, 5, 6
|
||||
triggeredBy: adminUser.id,
|
||||
changeDate: eightDaysAgo,
|
||||
monthKey: "2025-01",
|
||||
processedInProrationId: blockingProration.id,
|
||||
organizationBillingId: orgBilling?.id,
|
||||
metadata: { source: "seed-script", note: "Seat addition that failed to charge" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// More recent seat change attempt (2 seats, 2 days ago) - linked to warning proration
|
||||
// These are pending/invoice created but not yet charged
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await prisma.seatChangeLog.create({
|
||||
data: {
|
||||
teamId: org.id,
|
||||
changeType: "ADDITION",
|
||||
seatCount: 1,
|
||||
userId: null, // Simulating pending invites that haven't been accepted yet
|
||||
triggeredBy: adminUser.id,
|
||||
changeDate: twoDaysAgo,
|
||||
monthKey: "2025-02",
|
||||
processedInProrationId: warningProration.id,
|
||||
organizationBillingId: orgBilling?.id,
|
||||
metadata: { source: "seed-script", note: "Recent seat addition pending payment" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Proration and seat change records created");
|
||||
|
||||
return {
|
||||
organization: { id: org.id, name: org.name, slug: org.slug! },
|
||||
team: { id: team.id, name: team.name, slug: team.slug! },
|
||||
users: [
|
||||
{ email: adminUser.email, name: adminUser.name!, role: "OWNER" },
|
||||
{ email: memberUser.email, name: memberUser.name!, role: "MEMBER" },
|
||||
],
|
||||
prorations: [
|
||||
{ id: blockingProration.id, status: "FAILED", isBlocking: true },
|
||||
{ id: warningProration.id, status: "INVOICE_CREATED", isBlocking: false },
|
||||
],
|
||||
stripe: stripeResources,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== Proration Test Seed Script ===\n");
|
||||
console.log("Options:");
|
||||
console.log(` --skip-stripe: ${SKIP_STRIPE}`);
|
||||
console.log(` --cleanup: ${CLEANUP_FIRST}`);
|
||||
console.log("");
|
||||
|
||||
try {
|
||||
const result = await seedProrationTest();
|
||||
|
||||
console.log("\n=== Seed Complete ===\n");
|
||||
console.log("Organization:", result.organization);
|
||||
console.log("Team:", result.team);
|
||||
|
||||
console.log("\nTest Users:");
|
||||
result.users.forEach((u) => {
|
||||
console.log(` - ${u.name} (${u.email}) - Role: ${u.role}`);
|
||||
});
|
||||
console.log(` Password for all users: ${TEST_PASSWORD}`);
|
||||
|
||||
console.log("\nProration Records:");
|
||||
result.prorations.forEach((p) => {
|
||||
console.log(` - ${p.id}: ${p.status} (blocking: ${p.isBlocking})`);
|
||||
});
|
||||
|
||||
if (result.stripe.customer) {
|
||||
console.log("\nStripe Resources:");
|
||||
console.log(` Customer: ${result.stripe.customer.id}`);
|
||||
console.log(` Subscription: ${result.stripe.subscription?.id}`);
|
||||
console.log(` Product: ${result.stripe.product?.id}`);
|
||||
console.log(` Price: ${result.stripe.price?.id}`);
|
||||
}
|
||||
|
||||
console.log("\n=== Testing Instructions ===\n");
|
||||
console.log("1. Login as proration-admin@example.com");
|
||||
console.log(" - Should see the due invoice banner (error variant - blocking)");
|
||||
console.log(" - Should be blocked from inviting new members");
|
||||
console.log("");
|
||||
console.log("2. Login as proration-member@example.com");
|
||||
console.log(" - Should NOT see the due invoice banner (not a billing admin)");
|
||||
console.log("");
|
||||
console.log("3. To test sub-team exception:");
|
||||
console.log(" - Try inviting proration-member@example.com to the sub-team");
|
||||
console.log(" - This should succeed (existing org member)");
|
||||
console.log(" - Try inviting a new email - this should be blocked");
|
||||
console.log("");
|
||||
|
||||
if (result.stripe.customer) {
|
||||
console.log("=== Stripe Dashboard ===\n");
|
||||
console.log(`Customer: https://dashboard.stripe.com/test/customers/${result.stripe.customer.id}`);
|
||||
console.log(
|
||||
`Subscription: https://dashboard.stripe.com/test/subscriptions/${result.stripe.subscription?.id}`
|
||||
);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log("=== Cleanup ===\n");
|
||||
console.log("To clean up all test data, run:");
|
||||
console.log(
|
||||
" npx tsx packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts --cleanup --skip-stripe"
|
||||
);
|
||||
console.log("");
|
||||
} catch (error) {
|
||||
console.error("Error seeding data:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -323,6 +323,7 @@ export class MonthlyProrationService {
|
||||
|
||||
let invoiceId: string | null = null;
|
||||
let invoiceFinalized = false;
|
||||
let invoiceUrl: string | null = null;
|
||||
|
||||
try {
|
||||
const invoice = await this.billingService.createInvoice({
|
||||
@@ -335,8 +336,9 @@ export class MonthlyProrationService {
|
||||
|
||||
invoiceId = invoice.invoiceId;
|
||||
|
||||
await this.billingService.finalizeInvoice(invoiceId);
|
||||
const finalizedInvoice = await this.billingService.finalizeInvoice(invoiceId);
|
||||
invoiceFinalized = true;
|
||||
invoiceUrl = finalizedInvoice.invoiceUrl;
|
||||
|
||||
return await this.prorationRepository.updateProrationStatus(
|
||||
proration.id,
|
||||
@@ -344,6 +346,7 @@ export class MonthlyProrationService {
|
||||
{
|
||||
invoiceItemId,
|
||||
invoiceId,
|
||||
invoiceUrl: invoiceUrl ?? undefined,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ const mockBillingService: IBillingProviderService = {
|
||||
.mockResolvedValue({ invoiceItemId: "ii_test_123" }),
|
||||
deleteInvoiceItem: vi.fn().mockResolvedValue(undefined),
|
||||
createInvoice: vi.fn().mockResolvedValue({ invoiceId: "in_test_123" }),
|
||||
finalizeInvoice: vi.fn().mockResolvedValue(undefined),
|
||||
finalizeInvoice: vi.fn().mockResolvedValue({ invoiceUrl: "https://invoice.stripe.com/test" }),
|
||||
getSubscription: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
|
||||
+2
-1
@@ -72,7 +72,7 @@ const mockBillingService: IBillingProviderService = {
|
||||
createInvoiceItem: vi.fn().mockResolvedValue({ invoiceItemId: "ii_test_123" }),
|
||||
deleteInvoiceItem: vi.fn().mockResolvedValue(undefined),
|
||||
createInvoice: vi.fn().mockResolvedValue({ invoiceId: "in_test_123" }),
|
||||
finalizeInvoice: vi.fn().mockResolvedValue(undefined),
|
||||
finalizeInvoice: vi.fn().mockResolvedValue({ invoiceUrl: "https://invoice.stripe.com/test" }),
|
||||
getSubscription: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{ id: "si_test_123", quantity: 1, price: { unit_amount: 12000, recurring: { interval: "year" } } },
|
||||
@@ -370,6 +370,7 @@ describe("MonthlyProrationService", () => {
|
||||
expect(mockProrationRepository.updateProrationStatus).toHaveBeenCalledWith("proration-789", "PENDING", {
|
||||
invoiceItemId: "ii_test_123",
|
||||
invoiceId: "in_test_123",
|
||||
invoiceUrl: "https://invoice.stripe.com/test",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."MonthlyProration" ADD COLUMN "invoiceUrl" TEXT;
|
||||
@@ -3085,6 +3085,7 @@ model MonthlyProration {
|
||||
|
||||
invoiceItemId String?
|
||||
invoiceId String?
|
||||
invoiceUrl String?
|
||||
|
||||
status ProrationStatus @default(PENDING)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user