diff --git a/backend/groups/groupService.ts b/backend/groups/groupService.ts index a188d682..595c2ebb 100644 --- a/backend/groups/groupService.ts +++ b/backend/groups/groupService.ts @@ -1,9 +1,26 @@ +/** + * Group subscription service. + * Manages group plans, seats, invites, and billing metadata. + */ + +export type GroupRole = 'owner' | 'member'; +export type SubscriptionStatus = 'active' | 'canceled' | 'past_due' | 'trialing'; +export type BillingInterval = 'monthly' | 'yearly'; + export interface GroupMember { userId: string; - role: 'owner' | 'member'; + role: GroupRole; joinedAt: number; } +export interface SubscriptionPlan { + id: string; + name: string; + maxSeats: number; + pricePerSeat: number; + billingInterval: BillingInterval; +} + export interface Group { id: string; ownerId: string; @@ -11,54 +28,269 @@ export interface Group { maxSeats: number; members: GroupMember[]; billingCycleId: string; + planId: string; + subscriptionStatus: SubscriptionStatus; + nextBillingDate: number; + createdAt: number; + updatedAt: number; +} + +export interface Invite { + code: string; + groupId: string; + email: string; + invitedBy: string; + createdAt: number; + expiresAt: number; + used: boolean; +} + +export interface BillingInfo { + groupId: string; + planId: string; + maxSeats: number; + seatsUsed: number; + amountDue: number; + billingCycleId: string; + nextBillingDate: number; + status: SubscriptionStatus; +} + +export class GroupServiceError extends Error { + constructor(message: string, public readonly code: string) { + super(message); + this.name = 'GroupServiceError'; + } } export class GroupService { private groups: Map = new Map(); + private invites: Map = new Map(); + private plans: Map = new Map(); - createGroup(ownerId: string, name: string, maxSeats: number): Group { + constructor(plans?: SubscriptionPlan[]) { + const defaultPlans: SubscriptionPlan[] = [ + { id: 'basic-monthly', name: 'Basic Monthly', maxSeats: 3, pricePerSeat: 10, billingInterval: 'monthly' }, + { id: 'basic-yearly', name: 'Basic Yearly', maxSeats: 3, pricePerSeat: 8, billingInterval: 'yearly' }, + { id: 'pro-monthly', name: 'Pro Monthly', maxSeats: 10, pricePerSeat: 15, billingInterval: 'monthly' }, + { id: 'pro-yearly', name: 'Pro Yearly', maxSeats: 10, pricePerSeat: 12, billingInterval: 'yearly' }, + { id: 'enterprise', name: 'Enterprise', maxSeats: 100, pricePerSeat: 20, billingInterval: 'yearly' }, + ]; + const planList = plans && plans.length > 0 ? plans : defaultPlans; + planList.forEach(p => this.plans.set(p.id, p)); + } + + createGroup(ownerId: string, name: string, planId: string = 'basic-monthly'): Group { + if (!name || name.trim().length === 0) { + throw new GroupServiceError('Group name is required', 'INVALID_NAME'); + } + const plan = this.plans.get(planId); + if (!plan) { + throw new GroupServiceError('Invalid plan', 'INVALID_PLAN'); + } + const now = Date.now(); const group: Group = { - id: `group_${Date.now()}`, + id: `group_${now}`, ownerId, - name, - maxSeats, - members: [{ userId: ownerId, role: 'owner', joinedAt: Date.now() }], - billingCycleId: `cycle_${Date.now()}`, + name: name.trim(), + maxSeats: plan.maxSeats, + members: [{ userId: ownerId, role: 'owner', joinedAt: now }], + billingCycleId: `cycle_${now}`, + planId: plan.id, + subscriptionStatus: 'active', + nextBillingDate: this.calculateNextBillingDate(now, plan.billingInterval), + createdAt: now, + updatedAt: now, }; this.groups.set(group.id, group); return group; } - inviteMember(groupId: string, ownerId: string, email: string): string { + getGroup(groupId: string): Group { const group = this.groups.get(groupId); - if (!group) throw new Error('Group not found'); - if (group.ownerId !== ownerId) throw new Error('Only owner can invite'); - if (group.members.length >= group.maxSeats) throw new Error('No seats available'); - - // In a real implementation we would send the email here - return `invite_${Date.now()}`; + if (!group) { + throw new GroupServiceError('Group not found', 'GROUP_NOT_FOUND'); + } + return group; + } + + listGroups(): Group[] { + return Array.from(this.groups.values()); + } + + getPlans(): SubscriptionPlan[] { + return Array.from(this.plans.values()); + } + + inviteMember(groupId: string, inviterId: string, email: string): string { + const group = this.getGroup(groupId); + if (group.ownerId !== inviterId) { + throw new GroupServiceError('Only owner can invite members', 'UNAUTHORIZED'); + } + if (group.subscriptionStatus !== 'active' && group.subscriptionStatus !== 'trialing') { + throw new GroupServiceError('Subscription is not active', 'SUBSCRIPTION_INACTIVE'); + } + if (group.members.length >= group.maxSeats) { + throw new GroupServiceError('No seats available', 'NO_SEATS'); + } + if (!email || !this.isValidEmail(email)) { + throw new GroupServiceError('Invalid email', 'INVALID_EMAIL'); + } + const normalizedEmail = email.toLowerCase(); + const pendingInvite = Array.from(this.invites.values()).find( + i => i.groupId === groupId && i.email === normalizedEmail && !i.used && i.expiresAt > Date.now() + ); + if (pendingInvite) { + throw new GroupServiceError('An invite already exists for this email', 'DUPLICATE_INVITE'); + } + const now = Date.now(); + const invite: Invite = { + code: `invite_${now}_${Math.random().toString(36).substr(2, 9)}`, + groupId, + email: normalizedEmail, + invitedBy: inviterId, + createdAt: now, + expiresAt: now + 7 * 24 * 60 * 60 * 1000, // 7 days + used: false, + }; + this.invites.set(invite.code, invite); + // In a production system, send email with the invite code here. + return invite.code; } joinGroup(groupId: string, userId: string, inviteCode: string): Group { - const group = this.groups.get(groupId); - if (!group) throw new Error('Group not found'); - if (group.members.length >= group.maxSeats) throw new Error('Group is full'); - + const group = this.getGroup(groupId); + if (group.subscriptionStatus !== 'active' && group.subscriptionStatus !== 'trialing') { + throw new GroupServiceError('Subscription is not active', 'SUBSCRIPTION_INACTIVE'); + } + const invite = this.invites.get(inviteCode); + if (!invite || invite.used) { + throw new GroupServiceError('Invalid or expired invite code', 'INVALID_INVITE'); + } + if (invite.expiresAt < Date.now()) { + throw new GroupServiceError('Invite has expired', 'INVITE_EXPIRED'); + } + if (invite.groupId !== groupId) { + throw new GroupServiceError('Invite is not for this group', 'INVITE_MISMATCH'); + } + if (group.members.find(m => m.userId === userId)) { + throw new GroupServiceError('User is already a member', 'ALREADY_MEMBER'); + } + if (group.members.length >= group.maxSeats) { + throw new GroupServiceError('Group is full', 'GROUP_FULL'); + } + invite.used = true; + this.invites.set(inviteCode, invite); group.members.push({ userId, role: 'member', joinedAt: Date.now(), }); - + group.updatedAt = Date.now(); + this.groups.set(groupId, group); return group; } removeMember(groupId: string, ownerId: string, memberId: string): void { - const group = this.groups.get(groupId); - if (!group) throw new Error('Group not found'); - if (group.ownerId !== ownerId) throw new Error('Only owner can remove'); - if (ownerId === memberId) throw new Error('Owner cannot remove themselves'); - + const group = this.getGroup(groupId); + if (group.ownerId !== ownerId) { + throw new GroupServiceError('Only owner can remove members', 'UNAUTHORIZED'); + } + if (ownerId === memberId) { + throw new GroupServiceError('Owner cannot remove themselves', 'OWNER_SELF_REMOVAL'); + } + const memberExists = group.members.some(m => m.userId === memberId); + if (!memberExists) { + throw new GroupServiceError('Member not found', 'MEMBER_NOT_FOUND'); + } group.members = group.members.filter(m => m.userId !== memberId); + group.updatedAt = Date.now(); + this.groups.set(groupId, group); + } + + updatePlan(groupId: string, ownerId: string, newPlanId: string): Group { + const group = this.getGroup(groupId); + if (group.ownerId !== ownerId) { + throw new GroupServiceError('Only owner can change plan', 'UNAUTHORIZED'); + } + const newPlan = this.plans.get(newPlanId); + if (!newPlan) { + throw new GroupServiceError('Invalid plan', 'INVALID_PLAN'); + } + if (newPlan.id === group.planId) { + return group; + } + if (group.members.length > newPlan.maxSeats) { + throw new GroupServiceError('Plan does not have enough seats for current members', 'INSUFFICIENT_SEATS'); + } + group.planId = newPlan.id; + group.maxSeats = newPlan.maxSeats; + group.subscriptionStatus = 'active'; + group.nextBillingDate = this.calculateNextBillingDate(Date.now(), newPlan.billingInterval); + group.updatedAt = Date.now(); + this.groups.set(groupId, group); + return group; + } + + addSeats(groupId: string, ownerId: string, seatsToAdd: number): Group { + const group = this.getGroup(groupId); + if (group.ownerId !== ownerId) { + throw new GroupServiceError('Only owner can add seats', 'UNAUTHORIZED'); + } + if (seatsToAdd <= 0) { + throw new GroupServiceError('Seats must be positive', 'INVALID_SEAT_COUNT'); + } + // In a production system, this would trigger a prorated charge. + group.maxSeats += seatsToAdd; + group.updatedAt = Date.now(); + this.groups.set(groupId, group); + return group; + } + + getBillingInfo(groupId: string): BillingInfo { + const group = this.getGroup(groupId); + const plan = this.plans.get(group.planId); + if (!plan) { + throw new GroupServiceError('Plan not found', 'INVALID_PLAN'); + } + const seatsUsed = group.members.length; + return { + groupId: group.id, + planId: plan.id, + maxSeats: group.maxSeats, + seatsUsed, + amountDue: seatsUsed * plan.pricePerSeat, + billingCycleId: group.billingCycleId, + nextBillingDate: group.nextBillingDate, + status: group.subscriptionStatus, + }; + } + + cancelSubscription(groupId: string, ownerId: string): Group { + const group = this.getGroup(groupId); + if (group.ownerId !== ownerId) { + throw new GroupServiceError('Only owner can cancel subscription', 'UNAUTHORIZED'); + } + if (group.subscriptionStatus === 'canceled') { + throw new GroupServiceError('Subscription already canceled', 'ALREADY_CANCELED'); + } + group.subscriptionStatus = 'canceled'; + group.updatedAt = Date.now(); + this.groups.set(groupId, group); + return group; + } + + private isValidEmail(email: string): boolean { + return /^[\s]+@[^\s]+\.[^\s]+$/.test(email); + } + + private calculateNextBillingDate(fromDate: number, interval: BillingInterval): number { + const date = new Date(fromDate); + if (interval === 'monthly') { + date.setMonth(date.getMonth() + 1); + } else { + date.setFullYear(date.getFullYear() + 1); + } + return date.getTime(); } } diff --git a/backend/groups/router/groupBillingRouter.ts b/backend/groups/router/groupBillingRouter.ts index 753346ff..b90653a7 100644 --- a/backend/groups/router/groupBillingRouter.ts +++ b/backend/groups/router/groupBillingRouter.ts @@ -29,15 +29,15 @@ export function createGroupBillingRouter(): Router { const router = Router(); router.post('/groups', wrap(createGroup)); - router.get('/groups/:groupId', wrap(getGroup)); + router.get('/groups/:'groupId', wrap(getGroup)); router.post('/groups/:groupId/invites', wrap(inviteMember)); router.post('/groups/:groupId/invites/:inviteId/accept', wrap(acceptInvite)); router.delete('/groups/:groupId/members/:address', wrap(removeMember)); router.post('/groups/:groupId/charges', wrap(chargeGroup)); - router.get('/groups/:groupId/analytics', wrap(getAnalytics)); + router.get('/groups/:'groupId/analytics', wrap(getAnalytics)); router.get('/groups/:groupId/admin/actions', wrap(getAdminActions)); router.post('/groups/:groupId/admin/override-balance', wrap(overrideBalance)); router.post('/groups/:groupId/admin/change-role', wrap(changeRole)); return router; -} +} \ No newline at end of file diff --git a/backend/services/billing/alignmentService.ts b/backend/services/billing/alignmentService.ts index f991df45..9b93d41b 100644 --- a/backend/services/billing/alignmentService.ts +++ b/backend/services/billing/alignmentService.ts @@ -11,13 +11,19 @@ export interface AlignmentConfirmation { appliedAt: Date; } +export interface GroupAlignmentConfirmation { + previews: AlignmentPlanPreview[]; + appliedAt: Date; +} + /** * Server-side counterpart to the mobile billing-alignment store: tracks the - * 90-day re-alignment lockout per merchant/subscriber and produces alignment - * previews/confirmations from the same pure domain logic. + * 90-day re-alignment lockout per merchant/subscriber/group and produces + * alignment previews/confirmations from the same pure domain logic. */ export class AlignmentService { private lastAlignedAt = new Map(); + private lastGroupAlignedAt = new Map(); previewAlignment( userId: string, @@ -48,6 +54,36 @@ export class AlignmentService { this.lastAlignedAt.set(userId, now); return { preview, appliedAt: now }; } + + previewGroupAlignment( + groupId: string, + memberSubscriptions: Subscription[][], + targetDay: AlignmentTargetDay + ): AlignmentPlanPreview[] { + return memberSubscriptions.map(subs => buildAlignmentPlanPreview(subs, targetDay)); + } + + canRealignGroup(groupId: string, now: Date = new Date()): boolean { + return canRealign(this.lastGroupAlignedAt.get(groupId) ?? null, now); + } + + daysUntilNextGroupRealignment(groupId: string, now: Date = new Date()): number { + return daysUntilNextRealignment(this.lastGroupAlignedAt.get(groupId) ?? null, now); + } + + confirmGroupAlignment( + groupId: string, + memberSubscriptions: Subscription[][], + targetDay: AlignmentTargetDay, + now: Date = new Date() + ): GroupAlignmentConfirmation { + if (!this.canRealignGroup(groupId, now)) { + throw new Error(`Re-alignment for group ${groupId} is locked until the 90-day cooldown elapses`); + } + const previews = memberSubscriptions.map(subs => buildAlignmentPlanPreview(subs, targetDay)); + this.lastGroupAlignedAt.set(groupId, now); + return { previews, appliedAt: now }; + } } export const alignmentService = new AlignmentService(); diff --git a/backend/services/billing/billingEngine.ts b/backend/services/billing/billingEngine.ts index 4629bb0a..bce8245c 100644 --- a/backend/services/billing/billingEngine.ts +++ b/backend/services/billing/billingEngine.ts @@ -32,9 +32,25 @@ export interface BillingRecord { metadata: Record; } +export interface GroupMember { + address: string; + role: 'admin' | 'member'; + joinedAt: string; +} + +export interface GroupSubscription { + groupId: string; + masterSubscriptionId: string; + createdBy: string; + planType: PlanType; + members: GroupMember[]; + createdAt: string; +} + export class BillingEngine { private config: BillingEngineConfig; private billingHistory: BillingRecord[] = []; + private groupSubscriptions: Map = new Map(); constructor(config?: Partial) { this.config = { @@ -129,4 +145,84 @@ export class BillingEngine { getRecommendedStrategy(planType: PlanType): string { return PricingStrategyFactory.resolveStrategy({ planType }).name; } + + /** + * Create a group subscription plan. + */ + createGroupSubscription( + groupId: string, + masterSubscriptionId: string, + createdBy: string, + planType: PlanType + ): GroupSubscription { + const existing = this.groupSubscriptions.get(groupId); + if (existing) { + throw new Error(`Group subscription ${groupId} already exists`); + } + + const subscription: GroupSubscription = { + groupId, + masterSubscriptionId, + createdBy, + planType, + members: [{ address: createdBy, role: 'admin', joinedAt: new Date().toISOString() }], + createdAt: new Date().toISOString(), + }; + this.groupSubscriptions.set(groupId, subscription); + return subscription; + } + + /** + * Add a member to a group subscription. + */ + addMemberToGroup(groupId: string, memberAddress: string, role: 'admin' | 'member' = 'member'): void { + const group = this.getGroupSubscription(groupId); + if (group.members.some((m) => m.address === memberAddress)) { + throw new Error(`Member ${memberAddress} already in group ${groupId}`); + } + group.members.push({ + address: memberAddress, + role, + joinedAt: new Date().toISOString(), + }); + } + + /** + * Remove a member from a group subscription. + */ + removeMemberFromGroup(groupId: string, memberAddress: string): void { + const group = this.getGroupSubscription(groupId); + const initialLength = group.members.length; + group.members = group.members.filter((m) => m.address !== memberAddress); + if (group.members.length === initialLength) { + throw new Error(`Member ${memberAddress} not found in group ${groupId}`); + } + } + + /** + * Get a group subscription by ID. + */ + getGroupSubscription(groupId: string): GroupSubscription { + const group = this.groupSubscriptions.get(groupId); + if (!group) { + throw new Error(`Group subscription ${groupId} not found`); + } + return group; + } + + /** + * Get all group subscriptions for a subscriber address. + */ + getGroupsForSubscriber(address: string): GroupSubscription[] { + return Array.from(this.groupSubscriptions.values()).filter((g) => + g.members.some((m) => m.address === address) + ); + } + + /** + * Get all members of a group subscription. + */ + getGroupMembers(groupId: string): GroupMember[] { + return this.getGroupSubscription(groupId).members; + } } diff --git a/backend/services/billing/consolidationEngine.ts b/backend/services/billing/consolidationEngine.ts index dc9c253d..4b040910 100644 --- a/backend/services/billing/consolidationEngine.ts +++ b/backend/services/billing/consolidationEngine.ts @@ -8,13 +8,27 @@ import { groupForConsolidation } from '../../../src/utils/billingAlignment'; * Merges subscriptions that share a billing date into a single invoice. * Also decides whether a newly-purchased subscription should be * auto-consolidated with the subscriber's existing billing date. + * + * Group plan support: Subscriptions may include a `groupPlanId` and + * `memberIds`. When a new subscription is added to an existing group plan, + * it should consolidate with the group's shared billing date. This engine + * ensures group plans are aligned and provides member management helpers + * for billing purposes. */ export class ConsolidationEngine { - /** Groups of 2+ active, paid subscriptions sharing the same billing date. */ + /** + * Groups of 2+ active, paid subscriptions sharing the same billing date. + * Group plan subscriptions are grouped by their billing date as well. + */ findConsolidationGroups(subscriptions: Subscription[]): ConsolidationGroup[] { return groupForConsolidation(subscriptions); } + /** + * Builds a single consolidated invoice for the given subscriptions. + * For group plans, the invoice includes the plan's pricing and member + * breakdown as provided by the billing alignment utility. + */ consolidate( subscriptions: Subscription[], sequence: number, @@ -25,23 +39,112 @@ export class ConsolidationEngine { } /** - * Auto-consolidation for new multi-subscription purchases: a newly added - * subscription should adopt the subscriber's existing shared billing date - * (if one exists) rather than starting its own cycle. + * Determines if a newly purchased subscription should be auto-consolidated + * with the subscriber's existing billing date. + * + * A subscription should be consolidated if: + * - It has a positive price + * - There are existing consolidation groups + * - It is part of a group plan that already exists in the subscription list */ shouldAutoConsolidate(existingSubscriptions: Subscription[], newSubscription: Subscription): boolean { if (newSubscription.price <= 0) return false; + const groups = this.findConsolidationGroups(existingSubscriptions); - return groups.length > 0; + if (groups.length === 0) return false; + + // If the new subscription is a group plan, ensure its group already exists. + if (this.isGroupPlan(newSubscription)) { + return existingSubscriptions.some(sub => + (sub as any).groupPlanId === (newSubscription as any).groupPlanId && sub.id !== newSubscription.id + ); + } + + // Regular subscriptions consolidate if any group exists. + return true; } - /** Returns the shared billing date a new subscription should align to, if any. */ - getAutoConsolidationTarget(existingSubscriptions: Subscription[]): Date | null { + /** + * Returns the shared billing date a new subscription should align to, if any. + * + * For group plans, the target is the billing date of the group the + * subscription belongs to (if already active). Otherwise, the dominant + * (largest) consolidation group is used. + */ + getAutoConsolidationTarget( + existingSubscriptions: Subscription[], + newSubscription?: Subscription + ): Date | null { const groups = this.findConsolidationGroups(existingSubscriptions); if (groups.length === 0) return null; - // Prefer the group with the most members as the dominant billing date. - const dominant = [...groups].sort((a, b) => b.subscriptionIds.length - a.subscriptionIds.length)[0]; - return new Date(dominant.billingDateKey); + + let chosenGroup: ConsolidationGroup | undefined; + + // If a group plan is being added, try to align with its group. + if (newSubscription && this.isGroupPlan(newSubscription)) { + const matchingGroup = groups.find(group => + group.subscriptionIds.some(id => { + const sub = existingSubscriptions.find(s => s.id === id); + return sub && (sub as any).groupPlanId === (newSubscription as any).groupPlanId; + }) + ); + if (matchingGroup) { + chosenGroup = matchingGroup; + } + } + + // Fall back to the dominant (largest) group. + if (!chosenGroup) { + chosenGroup = [...groups].sort( + (a, b) => b.subscriptionIds.length - a.subscriptionIds.length + )[0]; + } + + const date = new Date(chosenGroup.billingDateKey); + if (isNaN(date.getTime())) { + // Invalid date key; return null to be safe. + return null; + } + return date; + } + + /** + * Checks whether a subscription is a group plan by looking for a + * `groupPlanId` and a `memberIds` array. + */ + isGroupPlan(subscription: Subscription): boolean { + return Boolean( + subscription && + typeof (subscription as any).groupPlanId === 'string' && + Array.isArray((subscription as any).memberIds) + ); + } + + /** + * Adds a member to a group plan subscription. Returns a new subscription + * object with updated memberIds. + */ + addMember(subscription: Subscription, memberId: string): Subscription { + if (!this.isGroupPlan(subscription)) { + throw new Error('Cannot add member to a non-group-plan subscription.'); + } + const memberIds = [...(subscription as any).memberIds]; + if (!memberIds.includes(memberId)) { + memberIds.push(memberId); + } + return { ...subscription, memberIds }; + } + + /** + * Removes a member from a group plan subscription. Returns a new subscription + * object with updated memberIds. + */ + removeMember(subscription: Subscription, memberId: string): Subscription { + if (!this.isGroupPlan(subscription)) { + throw new Error('Cannot remove member from a non-group-plan subscription.'); + } + const memberIds = (subscription as any).memberIds.filter((id: string) => id !== memberId); + return { ...subscription, memberIds }; } }