Skip to content

Commit 98eac4c

Browse files
authored
Merge pull request #697 from sparrowapp-dev/development
release: Admin Panel Changes
2 parents 8044a45 + 1e7148a commit 98eac4c

24 files changed

+749
-287
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,6 @@
145145
},
146146
"packageManager": "[email protected]",
147147
"optionalDependencies": {
148-
"@sparrowapp-dev/stripe-billing": "^1.1.7"
148+
"@sparrowapp-dev/stripe-billing": "^1.1.9"
149149
}
150150
}

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/modules/billing/billing.module.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import { PaymentEmailService } from "./services/payment-email.service";
1212
import { PaymentEmailHelper } from "./helpers/payment-email.helper";
1313
import { StripeWebhookHelper } from "./helpers/stripe-webhook.helper";
1414
import { StripeCustomerService } from "./services/stripe-customer.service";
15+
import { LicenseManagementService } from "./services/license-management.service";
1516
import { EmailService } from "@src/modules/common/services/email.service";
1617
import { AdminHubsRepository } from "@src/modules/user-admin/repositories/user-admin.hubs.repository";
18+
import { TeamRepository } from "@src/modules/identity/repositories/team.repository";
1719

1820
// Try to import the Stripe module, but don't crash if it's not available
1921
let StripeModule: any;
@@ -46,6 +48,8 @@ export class BillingModule {
4648
PaymentEmailService,
4749
PaymentEmailHelper,
4850
StripeCustomerService,
51+
LicenseManagementService,
52+
TeamRepository,
4953
EmailService,
5054
AdminHubsRepository,
5155
];
@@ -59,6 +63,7 @@ export class BillingModule {
5963
PaymentEmailService,
6064
PaymentEmailHelper,
6165
StripeCustomerService,
66+
LicenseManagementService,
6267
BillingAuditService,
6368
...(StripeModule ? [StripeModule] : []),
6469
];

src/modules/billing/helpers/payment-email.helper.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -603,8 +603,8 @@ export class PaymentEmailHelper {
603603
updatePaymentUrl: `${process.env.FRONTEND_URL || "https://app.sparrowapp.dev"}/billing/${metadata.hubId}`,
604604
// Include seat data for action required email logic
605605
totalSeats: team.licenses?.totalSeats || 0,
606-
usedSeats: team.licenses?.usedSeats || 0,
607-
availableSeats: team.licenses?.availableSeats || 0,
606+
usedSeats: team?.users?.length || 0,
607+
invitedSeats: team?.invites?.length || 0,
608608
};
609609
}
610610

src/modules/billing/helpers/stripe-webhook.helper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,8 @@ export class StripeWebhookHelper {
332332
const teamWithUpcomingInvoice =
333333
await this.stripeSubscriptionRepo.findTeamById(upcomingMetadata.hubId);
334334

335-
// Send upcoming payment email notification
336335
if (teamWithUpcomingInvoice) {
336+
// Send upcoming payment email notification
337337
try {
338338
await this.paymentEmailHelper.sendUpcomingPaymentEmail(
339339
event.data.object,

src/modules/billing/repositories/stripe-subscription.repository.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
22
import { Collections } from "@src/modules/common/enum/database.collection.enum";
33
import {
44
BillingType,
5+
PaymentProvider,
56
SubscriptionStatus,
67
} from "@src/modules/common/enum/billing.enum";
78
import { Db, ObjectId, UpdateResult } from "mongodb";
@@ -183,4 +184,48 @@ export class StripeSubscriptionRepository {
183184
throw error;
184185
}
185186
}
187+
188+
/**
189+
* Find teams with subscriptions ending in the given time window
190+
* @param start Start datetime (e.g., now)
191+
* @param end End datetime (e.g., now + 12 hours)
192+
* @returns Array of team documents with subscriptions ending in range
193+
*/
194+
async findTeamsWithSubscriptionsEndingInRange(
195+
start: Date,
196+
end: Date,
197+
): Promise<any[]> {
198+
try {
199+
return await this.db
200+
.collection(Collections.TEAM)
201+
.find({
202+
$and: [
203+
{
204+
"billing.current_period_end": {
205+
$gte: start,
206+
$lte: end,
207+
},
208+
},
209+
{
210+
"billing.status": SubscriptionStatus.ACTIVE,
211+
},
212+
{
213+
"plan.name": { $ne: PlanName.COMMUNITY },
214+
},
215+
{
216+
"billing.paymentProviders": {
217+
$elemMatch: {
218+
provider: PaymentProvider.STRIPE,
219+
subscriptionId: { $exists: true, $ne: null },
220+
},
221+
},
222+
},
223+
],
224+
})
225+
.toArray();
226+
} catch (error) {
227+
console.error("Error fetching teams with expiring subscriptions:", error);
228+
throw error;
229+
}
230+
}
186231
}

src/modules/billing/services/billing-audit.service.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
BillingEventType,
55
BillingEntityType,
66
BillingTransactionType,
7+
BillingActorType,
8+
BillingSource,
79
} from "@src/modules/common/enum/billing.enum";
810
import { PlanName } from "@src/modules/common/enum/plan.enum";
911
import {
@@ -521,4 +523,72 @@ export class BillingAuditService {
521523

522524
return changes;
523525
}
526+
527+
/**
528+
* Record a license change event (seats reserved, freed, or optimized)
529+
*/
530+
async recordLicenseChange(
531+
entityId: string,
532+
eventType:
533+
| BillingEventType.SEAT_RESERVED_BY_USER_ADDITION
534+
| BillingEventType.SEAT_RELEASED_BY_USER_REMOVAL
535+
| BillingEventType.SEATS_CLEANED_UP_AS_UNUSED,
536+
previousLicense: any,
537+
newLicense: any,
538+
context: {
539+
actor: { type: BillingActorType; name: string };
540+
source: BillingSource;
541+
externalId?: string;
542+
reason?: string;
543+
},
544+
metadata?: Record<string, any>,
545+
): Promise<string> {
546+
// Calculate license changes
547+
const changes = this.calculateLicenseChanges(previousLicense, newLicense);
548+
549+
if (changes.length === 0) {
550+
console.warn("No license changes detected, skipping audit log");
551+
return "";
552+
}
553+
554+
return await this.recordBillingEvent({
555+
eventType,
556+
entityType: BillingEntityType.HUB,
557+
entityId,
558+
changes,
559+
context,
560+
metadata,
561+
});
562+
}
563+
564+
/**
565+
* Calculate changes between license states
566+
*/
567+
private calculateLicenseChanges(
568+
previousLicense: any,
569+
newLicense: any,
570+
): Array<{ field: string; previousValue: any; newValue: any }> {
571+
const changes: Array<{
572+
field: string;
573+
previousValue: any;
574+
newValue: any;
575+
}> = [];
576+
577+
const licenseFields = ["totalSeats", "usedSeats", "availableSeats"];
578+
579+
for (const field of licenseFields) {
580+
const previousValue = previousLicense?.[field];
581+
const newValue = newLicense?.[field];
582+
583+
if (previousValue !== newValue) {
584+
changes.push({
585+
field,
586+
previousValue: previousValue || null,
587+
newValue: newValue || null,
588+
});
589+
}
590+
}
591+
592+
return changes;
593+
}
524594
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { Injectable } from "@nestjs/common";
2+
import { ObjectId } from "mongodb";
3+
import { LicensesDto } from "@src/modules/common/models/licenses.model";
4+
import { TeamRepository } from "@src/modules/identity/repositories/team.repository";
5+
import { BillingAuditService } from "./billing-audit.service";
6+
import {
7+
BillingEventType,
8+
BillingActorType,
9+
BillingSource,
10+
} from "@src/modules/common/enum/billing.enum";
11+
12+
/**
13+
* License Management Service
14+
* Centralized service for handling license tracking across teams
15+
*/
16+
@Injectable()
17+
export class LicenseManagementService {
18+
constructor(
19+
private readonly teamRepository: TeamRepository,
20+
private readonly billingAuditService: BillingAuditService,
21+
) {}
22+
23+
/**
24+
* Log license changes for audit purposes
25+
* @param teamId - The team ID
26+
* @param eventType - The type of license event
27+
* @param context - Additional context for the event
28+
*/
29+
private async logLicenseChange(
30+
teamId: string,
31+
eventType:
32+
| BillingEventType.SEAT_RESERVED_BY_USER_ADDITION
33+
| BillingEventType.SEAT_RELEASED_BY_USER_REMOVAL
34+
| BillingEventType.SEATS_CLEANED_UP_AS_UNUSED,
35+
context: string,
36+
previousLicense?: LicensesDto,
37+
newLicense?: LicensesDto,
38+
): Promise<void> {
39+
try {
40+
if (!previousLicense || !newLicense) return;
41+
42+
await this.billingAuditService.recordLicenseChange(
43+
teamId,
44+
eventType,
45+
previousLicense,
46+
newLicense,
47+
{
48+
actor: { type: BillingActorType.SYSTEM, name: "License Management" },
49+
source: BillingSource.API_CALL,
50+
reason: context,
51+
},
52+
{
53+
context,
54+
timestamp: new Date(),
55+
},
56+
);
57+
} catch (error) {
58+
console.error("Error logging license change:", error);
59+
// Don't throw to avoid breaking main operation
60+
}
61+
}
62+
63+
/**
64+
* Updates license tracking for a team
65+
* @param teamId - The team ID to update licenses for
66+
*/
67+
async updateLicenseTracking(teamId: string): Promise<void> {
68+
try {
69+
const team = await this.teamRepository.findTeamByTeamId(
70+
new ObjectId(teamId),
71+
);
72+
73+
if (!team) {
74+
console.warn(`Team not found for license tracking: ${teamId}`);
75+
return;
76+
}
77+
78+
// Store previous license state for audit
79+
const previousLicense: LicensesDto = team.licenses || {
80+
totalSeats: Number(team.licenses?.totalSeats || 1),
81+
usedSeats: Number(team.licenses?.usedSeats || 0),
82+
availableSeats: Number(team.licenses?.availableSeats || 1),
83+
lastUpdated: new Date(),
84+
};
85+
86+
// Calculate current active users and pending invites
87+
const currentActiveUsers = team.users?.length || 0;
88+
const currentPendingInvites =
89+
team.invites?.filter((invite: any) => !invite.isAccepted).length || 0;
90+
const totalCurrentUsage = currentActiveUsers + currentPendingInvites;
91+
92+
// Use provided seats or get from existing licenses/billing
93+
const totalSeats = team.licenses?.totalSeats || team.billing?.seats || 1;
94+
95+
const licenseData: LicensesDto = {
96+
totalSeats: Number(totalSeats),
97+
usedSeats: Number(totalCurrentUsage),
98+
availableSeats: Number(totalSeats) - Number(totalCurrentUsage),
99+
lastUpdated: new Date(),
100+
};
101+
102+
// Update team with license data
103+
await this.teamRepository.updateTeamById(new ObjectId(teamId), {
104+
licenses: licenseData,
105+
});
106+
107+
// Log license change audit event - determine event type based on usage change
108+
const previousUsedSeats = previousLicense.usedSeats || 0;
109+
const newUsedSeats = licenseData.usedSeats;
110+
111+
if (newUsedSeats > previousUsedSeats) {
112+
// Users were added - seats reserved
113+
await this.logLicenseChange(
114+
teamId,
115+
BillingEventType.SEAT_RESERVED_BY_USER_ADDITION,
116+
"User added to team - seat reserved",
117+
previousLicense,
118+
licenseData,
119+
);
120+
} else if (newUsedSeats < previousUsedSeats) {
121+
// Users were removed - seats freed
122+
await this.logLicenseChange(
123+
teamId,
124+
BillingEventType.SEAT_RELEASED_BY_USER_REMOVAL,
125+
"User removed from team - seat freed",
126+
previousLicense,
127+
licenseData,
128+
);
129+
}
130+
// If no change in used seats, no audit event needed
131+
} catch (error) {
132+
console.error("Error updating license tracking:", error);
133+
// Don't throw error to avoid breaking the main operation
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)