Implement updateSubscription feature and refactor billing services
This commit introduces the updateSubscription method to the BillingStrategyProviderService, ensuring that subscriptions can be updated within the billing core. Additionally, a refactor has been applied to the BillingGatewayFactoryService and stripe-billing-strategy.service to improve error handling and the robustness of subscription updates. Logging in the webhook route has been adjusted for clarity and the data model has been enhanced.
This commit is contained in:
@@ -17,6 +17,7 @@ import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import { RenewInvitationSchema } from '../../schema/renew-invitation.schema';
|
||||
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||
import { AccountInvitationsService } from '../services/account-invitations.service';
|
||||
import { AccountPerSeatBillingService } from '../services/account-per-seat-billing.service';
|
||||
|
||||
/**
|
||||
* Creates invitations for inviting members.
|
||||
@@ -98,15 +99,21 @@ export async function acceptInvitationAction(data: FormData) {
|
||||
Object.fromEntries(data),
|
||||
);
|
||||
|
||||
const accountPerSeatBillingService = new AccountPerSeatBillingService(client);
|
||||
const user = await assertSession(client);
|
||||
|
||||
const service = new AccountInvitationsService(client);
|
||||
|
||||
await service.acceptInvitationToTeam({
|
||||
adminClient: getSupabaseServerActionClient({ admin: true }),
|
||||
inviteToken,
|
||||
userId: user.id,
|
||||
});
|
||||
// Accept the invitation
|
||||
const accountId = await service.acceptInvitationToTeam(
|
||||
getSupabaseServerActionClient({ admin: true }),
|
||||
{
|
||||
inviteToken,
|
||||
userId: user.id,
|
||||
},
|
||||
);
|
||||
|
||||
await accountPerSeatBillingService.increaseSeats(accountId);
|
||||
|
||||
return redirect(nextPath);
|
||||
}
|
||||
|
||||
@@ -37,8 +37,6 @@ export class AccountInvitationsWebhookService {
|
||||
}
|
||||
|
||||
private async dispatchInvitationEmail(invitation: Invitation) {
|
||||
const mailer = new Mailer();
|
||||
|
||||
const inviter = await this.client
|
||||
.from('accounts')
|
||||
.select('email, name')
|
||||
@@ -70,7 +68,7 @@ export class AccountInvitationsWebhookService {
|
||||
teamName: team.data.name,
|
||||
});
|
||||
|
||||
await mailer.sendEmail({
|
||||
await Mailer.sendEmail({
|
||||
from: env.emailSender,
|
||||
to: invitation.email,
|
||||
subject: 'You have been invited to join a team',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||
|
||||
export class AccountInvitationsService {
|
||||
private namespace = 'accounts.invitations';
|
||||
private readonly namespace = 'invitations';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
@@ -76,7 +76,11 @@ export class AccountInvitationsService {
|
||||
accountSlug: string;
|
||||
}) {
|
||||
Logger.info(
|
||||
{ account: accountSlug, invitations, name: this.namespace },
|
||||
{
|
||||
account: accountSlug,
|
||||
invitations,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Storing invitations',
|
||||
);
|
||||
|
||||
@@ -87,6 +91,14 @@ export class AccountInvitationsService {
|
||||
.single();
|
||||
|
||||
if (!accountResponse.data) {
|
||||
Logger.error(
|
||||
{
|
||||
accountSlug,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Account not found in database. Cannot send invitations.',
|
||||
);
|
||||
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
@@ -96,6 +108,15 @@ export class AccountInvitationsService {
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
Logger.error(
|
||||
{
|
||||
accountSlug,
|
||||
error: response.error,
|
||||
name: this.namespace,
|
||||
},
|
||||
`Failed to add invitations to account ${accountSlug}`,
|
||||
);
|
||||
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
@@ -116,12 +137,14 @@ export class AccountInvitationsService {
|
||||
/**
|
||||
* Accepts an invitation to join a team.
|
||||
*/
|
||||
async acceptInvitationToTeam(params: {
|
||||
userId: string;
|
||||
inviteToken: string;
|
||||
adminClient: SupabaseClient<Database>;
|
||||
}) {
|
||||
const { error, data } = await params.adminClient.rpc('accept_invitation', {
|
||||
async acceptInvitationToTeam(
|
||||
adminClient: SupabaseClient<Database>,
|
||||
params: {
|
||||
userId: string;
|
||||
inviteToken: string;
|
||||
},
|
||||
) {
|
||||
const { error, data } = await adminClient.rpc('accept_invitation', {
|
||||
token: params.inviteToken,
|
||||
user_id: params.userId,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Database } from '@kit/supabase/database';
|
||||
import { RemoveMemberSchema } from '../../schema/remove-member.schema';
|
||||
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
|
||||
import { UpdateMemberRoleSchema } from '../../schema/update-member-role.schema';
|
||||
import { AccountPerSeatBillingService } from './account-per-seat-billing.service';
|
||||
|
||||
export class AccountMembersService {
|
||||
private readonly namespace = 'account-members';
|
||||
@@ -16,6 +17,13 @@ export class AccountMembersService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async removeMemberFromAccount(params: z.infer<typeof RemoveMemberSchema>) {
|
||||
const ctx = {
|
||||
namespace: this.namespace,
|
||||
...params,
|
||||
};
|
||||
|
||||
Logger.info(ctx, `Removing member from account...`);
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('accounts_memberships')
|
||||
.delete()
|
||||
@@ -25,13 +33,37 @@ export class AccountMembersService {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
Logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
`Failed to remove member from account`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
ctx,
|
||||
`Successfully removed member from account. Verifying seat count...`,
|
||||
);
|
||||
|
||||
const service = new AccountPerSeatBillingService(this.client);
|
||||
|
||||
await service.decreaseSeats(params.accountId);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateMemberRole(params: z.infer<typeof UpdateMemberRoleSchema>) {
|
||||
const ctx = {
|
||||
namespace: this.namespace,
|
||||
...params,
|
||||
};
|
||||
|
||||
Logger.info(ctx, `Updating member role...`);
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('accounts_memberships')
|
||||
.update({
|
||||
@@ -43,9 +75,19 @@ export class AccountMembersService {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
Logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
`Failed to update member role`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
Logger.info(ctx, `Successfully updated member role`);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -57,7 +99,7 @@ export class AccountMembersService {
|
||||
...params,
|
||||
};
|
||||
|
||||
Logger.info(ctx, `Transferring ownership of account`);
|
||||
Logger.info(ctx, `Transferring ownership of account...`);
|
||||
|
||||
const { data, error } = await this.client.rpc(
|
||||
'transfer_team_account_ownership',
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { BillingGatewayService } from '@kit/billing-gateway';
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export class AccountPerSeatBillingService {
|
||||
private readonly namespace = 'accounts.per-seat-billing';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async getPerSeatSubscriptionItem(accountId: string) {
|
||||
Logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
},
|
||||
`Getting per-seat subscription item for account ${accountId}...`,
|
||||
);
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('subscriptions')
|
||||
.select(
|
||||
`
|
||||
provider: billing_provider,
|
||||
id,
|
||||
subscription_items !inner (
|
||||
quantity,
|
||||
id: variant_id,
|
||||
type
|
||||
)
|
||||
`,
|
||||
)
|
||||
.eq('account_id', accountId)
|
||||
.eq('subscription_items.type', 'per-seat')
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
Logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
error,
|
||||
},
|
||||
`Failed to get per-seat subscription item for account ${accountId}`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data?.subscription_items) {
|
||||
Logger.info(
|
||||
{ name: this.namespace, accountId },
|
||||
`No per-seat subscription item found for account ${accountId}. Exiting...`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
},
|
||||
`Per-seat subscription item found for account ${accountId}. Will update...`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async increaseSeats(accountId: string) {
|
||||
const subscription = await this.getPerSeatSubscriptionItem(accountId);
|
||||
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionItems = subscription.subscription_items.filter((item) => {
|
||||
return item.type === 'per_seat';
|
||||
});
|
||||
|
||||
if (!subscriptionItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const billingGateway = new BillingGatewayService(subscription.provider);
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItems,
|
||||
},
|
||||
`Increasing seats for account ${accountId}...`,
|
||||
);
|
||||
|
||||
const promises = subscriptionItems.map(async (item) => {
|
||||
try {
|
||||
Logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity + 1,
|
||||
},
|
||||
`Updating subscription item...`,
|
||||
);
|
||||
|
||||
await billingGateway.updateSubscriptionItem({
|
||||
subscriptionId: subscription.id,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity + 1,
|
||||
});
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity + 1,
|
||||
},
|
||||
`Subscription item updated successfully`,
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
error,
|
||||
},
|
||||
`Failed to increase seats for account ${accountId}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
async decreaseSeats(accountId: string) {
|
||||
const subscription = await this.getPerSeatSubscriptionItem(accountId);
|
||||
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionItems = subscription.subscription_items.filter((item) => {
|
||||
return item.type === 'per_seat';
|
||||
});
|
||||
|
||||
if (!subscriptionItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItems,
|
||||
},
|
||||
`Decreasing seats for account ${accountId}...`,
|
||||
);
|
||||
|
||||
const billingGateway = new BillingGatewayService(subscription.provider);
|
||||
|
||||
const promises = subscriptionItems.map(async (item) => {
|
||||
try {
|
||||
Logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity - 1,
|
||||
},
|
||||
`Updating subscription item...`,
|
||||
);
|
||||
|
||||
await billingGateway.updateSubscriptionItem({
|
||||
subscriptionId: subscription.id,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity - 1,
|
||||
});
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
subscriptionItemId: item.id,
|
||||
quantity: item.quantity - 1,
|
||||
},
|
||||
`Subscription item updated successfully`,
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
name: this.namespace,
|
||||
accountId,
|
||||
error,
|
||||
},
|
||||
`Failed to decrease seats for account ${accountId}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user