Skip to content

Commit be4f3b2

Browse files
committed
feat(billing): implement promo usage reservation and rollback mechanism in billing logic
1 parent 2f8e5b5 commit be4f3b2

5 files changed

Lines changed: 278 additions & 53 deletions

File tree

src/billing/cloudpayments.ts

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ export default class CloudPaymentsWebhooks {
163163
const recurrentPaymentSettings = data.cloudPayments?.recurrent;
164164
let promoPricing;
165165

166+
/**
167+
* Record promo usage before applying paid benefits.
168+
*
169+
* /pay runs after CloudPayments has accepted the charge, but workspace plan
170+
* must not be changed if promo usage cannot be stored. Otherwise a transient
171+
* DB/limit error would grant a discounted plan without consuming the promo.
172+
*/
166173
if (data.promo && !data.isCardLinkOperation) {
167174
try {
168175
const promoCodeService = new PromoCodeService(context.factories);
@@ -301,6 +308,36 @@ export default class CloudPaymentsWebhooks {
301308
return;
302309
}
303310

311+
if (data.promo && !data.isCardLinkOperation) {
312+
try {
313+
const promoCodeService = new PromoCodeService(req.context.factories);
314+
const promoPricing = await promoCodeService.getPricingForPromoCodeId(
315+
data.promo.id,
316+
data.userId,
317+
data.workspaceId,
318+
tariffPlan
319+
);
320+
321+
await promoCodeService.createUsage({
322+
promoCode: promoPricing.promoCode,
323+
userId: data.userId,
324+
workspaceId: workspace._id,
325+
planId: tariffPlan._id,
326+
benefitType: promoPricing.benefitType,
327+
originalAmount: promoPricing.originalAmount,
328+
finalAmount: promoPricing.finalAmount,
329+
discountAmount: promoPricing.discountAmount,
330+
utm: data.promo.utm,
331+
});
332+
} catch (e) {
333+
const error = e as Error;
334+
335+
this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Failed to record promo usage: ${error.toString()}`, body);
336+
337+
return;
338+
}
339+
}
340+
304341
try {
305342
await businessOperation.setStatus(BusinessOperationStatus.Confirmed);
306343

@@ -334,34 +371,6 @@ export default class CloudPaymentsWebhooks {
334371
return;
335372
}
336373

337-
if (data.promo && !data.isCardLinkOperation) {
338-
try {
339-
const promoCodeService = new PromoCodeService(req.context.factories);
340-
const promoPricing = await promoCodeService.getPricingForPromoCodeId(
341-
data.promo.id,
342-
data.userId,
343-
data.workspaceId,
344-
tariffPlan
345-
);
346-
347-
await promoCodeService.createUsage({
348-
promoCode: promoPricing.promoCode,
349-
userId: data.userId,
350-
workspaceId: workspace._id,
351-
planId: tariffPlan._id,
352-
benefitType: promoPricing.benefitType,
353-
originalAmount: promoPricing.originalAmount,
354-
finalAmount: promoPricing.finalAmount,
355-
discountAmount: promoPricing.discountAmount,
356-
utm: data.promo.utm,
357-
});
358-
} catch (e) {
359-
const error = e as Error;
360-
361-
console.error(`[Billing / Pay] Failed to record promo usage: ${error.toString()}`, body);
362-
}
363-
}
364-
365374
// let accountId = workspace.accountId;
366375

367376
/*
@@ -824,10 +833,29 @@ status: ${body.Status}`
824833
*/
825834
if (body.Data) {
826835
const parsedData = JSON.parse(body.Data || '{}') as WebhookData;
836+
const checksumData = checksumService.parseAndVerifyChecksum(parsedData.checksum);
837+
838+
/**
839+
* Treat checksum as the source of truth for billing intent.
840+
*
841+
* Widget Data is client-controlled, so it must not override signed fields like
842+
* workspaceId, tariffPlanId, userId, shouldSaveCard, or promo id. Only
843+
* CloudPayments recurrent settings are accepted from Data because they are
844+
* validated separately against server-side pricing in /check.
845+
*/
846+
if ('isCardLinkOperation' in checksumData) {
847+
return {
848+
...checksumData,
849+
tariffPlanId: '',
850+
shouldSaveCard: false,
851+
...(parsedData.cloudPayments ? { cloudPayments: parsedData.cloudPayments } : {}),
852+
};
853+
}
827854

828855
return {
829-
...checksumService.parseAndVerifyChecksum(parsedData.checksum),
830-
...parsedData,
856+
...checksumData,
857+
...(parsedData.cloudPayments ? { cloudPayments: parsedData.cloudPayments } : {}),
858+
isCardLinkOperation: false,
831859
};
832860
}
833861

src/models/promoCodeUsagesFactory.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,28 +98,39 @@ export default class PromoCodeUsagesFactory extends AbstractModelFactory<PromoCo
9898
return new PromoCodeUsageModel(usage);
9999
}
100100

101+
/**
102+
* Deletes usage by id.
103+
*
104+
* Used only as compensation when promo benefit application fails after usage reservation.
105+
*
106+
* @param usageId - promo usage id
107+
*/
108+
public async deleteById(usageId: ObjectId): Promise<void> {
109+
await this.collection.deleteOne({
110+
_id: usageId,
111+
});
112+
}
113+
101114
/**
102115
* Ensures promo usage indexes exist before queries.
103116
*
104117
* MongoDB createIndex is idempotent: after API restart it reuses an existing index
105118
* with the same keys/options and does not throw if the index is already present.
106119
*/
107120
private async ensureIndexesOnce(): Promise<void> {
108-
if (!this.indexesPromise) {
109-
this.indexesPromise = Promise.all([
110-
this.collection.createIndex({ promoCodeId: 1 }),
111-
this.collection.createIndex({
112-
promoCodeId: 1,
113-
userId: 1,
114-
}, { unique: true }),
115-
this.collection.createIndex({
116-
promoCodeId: 1,
117-
workspaceId: 1,
118-
}, { unique: true }),
119-
this.collection.createIndex({ workspaceId: 1 }),
120-
this.collection.createIndex({ userId: 1 }),
121-
]).then(() => undefined);
122-
}
121+
this.indexesPromise ??= Promise.all([
122+
this.collection.createIndex({ promoCodeId: 1 }),
123+
this.collection.createIndex({
124+
promoCodeId: 1,
125+
userId: 1,
126+
}, { unique: true }),
127+
this.collection.createIndex({
128+
promoCodeId: 1,
129+
workspaceId: 1,
130+
}, { unique: true }),
131+
this.collection.createIndex({ workspaceId: 1 }),
132+
this.collection.createIndex({ userId: 1 }),
133+
]).then(() => undefined);
123134

124135
await this.indexesPromise;
125136
}

src/services/promoCodeService.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from '@hawk.so/types';
66
import PlanModel from '../models/plan';
77
import PromoCodeModel from '../models/promoCode';
8+
import PromoCodeUsageModel from '../models/promoCodeUsage';
89
import WorkspaceModel from '../models/workspace';
910
import { ContextFactories } from '../types/graphql';
1011
import type { Utm } from '@hawk.so/types';
@@ -498,10 +499,13 @@ export default class PromoCodeService {
498499
try {
499500
const now = new Date();
500501

501-
await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId);
502-
await workspace.updateLastChargeDate(now);
503-
await workspace.changePlan(plan._id);
504-
await this.createUsage({
502+
/**
503+
* Reserve usage before granting the plan.
504+
*
505+
* This makes promo usage a precondition for the benefit: if limits are exhausted
506+
* or the insert fails, workspace state is not changed.
507+
*/
508+
const usage = await this.createUsage({
505509
promoCode,
506510
userId,
507511
workspaceId: workspace._id,
@@ -510,6 +514,20 @@ export default class PromoCodeService {
510514
utm,
511515
});
512516

517+
try {
518+
await workspace.updatePlanHistory(workspace.tariffPlanId.toString(), now, userId);
519+
await workspace.updateLastChargeDate(now);
520+
await workspace.changePlan(plan._id);
521+
} catch (error) {
522+
try {
523+
await this.factories.promoCodeUsagesFactory.deleteById(usage._id);
524+
} catch (rollbackError) {
525+
console.error('Failed to rollback promo usage after grant_plan apply failure', rollbackError);
526+
}
527+
528+
throw error;
529+
}
530+
513531
return plan;
514532
} catch (error) {
515533
if (error instanceof PromoCodeError) {
@@ -521,9 +539,13 @@ export default class PromoCodeService {
521539
}
522540

523541
/**
524-
* Creates usage after successful payment.
542+
* Creates usage after successful payment or before immediate grant_plan apply.
543+
*
544+
* Unique indexes on promoCodeId + userId/workspaceId make this method the durable
545+
* reservation point. Callers should grant the promo benefit only after it succeeds.
525546
*
526547
* @param params - usage creation params
548+
* @returns created promo usage
527549
*/
528550
public async createUsage(params: {
529551
promoCode: PromoCodeModel;
@@ -535,11 +557,11 @@ export default class PromoCodeService {
535557
finalAmount?: number;
536558
discountAmount?: number;
537559
utm?: PromoCodeUtm;
538-
}): Promise<void> {
560+
}): Promise<PromoCodeUsageModel> {
539561
await this.validateUsageLimits(params.promoCode, params.userId, params.workspaceId);
540562

541563
try {
542-
await this.factories.promoCodeUsagesFactory.create({
564+
return await this.factories.promoCodeUsagesFactory.create({
543565
promoCodeId: params.promoCode._id,
544566
userId: params.userId,
545567
workspaceId: params.workspaceId,

test/billing/cloudpayments.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import '../../src/env-test';
2+
3+
jest.mock('cloudpayments', () => ({
4+
ClientService: jest.fn().mockImplementation(() => ({
5+
getReceiptApi: jest.fn().mockReturnValue({}),
6+
getClientApi: jest.fn().mockReturnValue({}),
7+
})),
8+
ReceiptTypes: {
9+
Income: 'Income',
10+
},
11+
TaxationSystem: {
12+
Common: 'Common',
13+
},
14+
}));
15+
16+
jest.mock('../../src/mongo', () => ({
17+
databases: {
18+
hawk: {
19+
collection: jest.fn().mockReturnValue({}),
20+
},
21+
},
22+
}));
23+
24+
import { ObjectId } from 'mongodb';
25+
import CloudPaymentsWebhooks from '../../src/billing/cloudpayments';
26+
import checksumService from '../../src/utils/checksumService';
27+
28+
process.env.JWT_SECRET_BILLING_CHECKSUM = 'checksum_secret';
29+
30+
describe('CloudPaymentsWebhooks', () => {
31+
describe('getDataFromRequest()', () => {
32+
it('should trust checksum fields over unsigned widget Data fields', async () => {
33+
const promoId = new ObjectId().toString();
34+
const unsignedPromoId = new ObjectId().toString();
35+
const checksum = await checksumService.generateChecksum({
36+
workspaceId: 'signed-workspace',
37+
userId: 'signed-user',
38+
tariffPlanId: 'signed-plan',
39+
shouldSaveCard: false,
40+
nextPaymentDate: new Date().toISOString(),
41+
promo: {
42+
id: promoId,
43+
},
44+
});
45+
const webhooks = new CloudPaymentsWebhooks() as any;
46+
47+
const data = await webhooks.getDataFromRequest({
48+
body: {
49+
Data: JSON.stringify({
50+
checksum,
51+
workspaceId: 'unsigned-workspace',
52+
userId: 'unsigned-user',
53+
tariffPlanId: 'unsigned-plan',
54+
shouldSaveCard: true,
55+
promo: {
56+
id: unsignedPromoId,
57+
},
58+
cloudPayments: {
59+
recurrent: {
60+
interval: 'Month',
61+
period: 1,
62+
},
63+
},
64+
}),
65+
},
66+
});
67+
68+
expect(data).toMatchObject({
69+
workspaceId: 'signed-workspace',
70+
userId: 'signed-user',
71+
tariffPlanId: 'signed-plan',
72+
shouldSaveCard: false,
73+
promo: {
74+
id: promoId,
75+
},
76+
cloudPayments: {
77+
recurrent: {
78+
interval: 'Month',
79+
period: 1,
80+
},
81+
},
82+
isCardLinkOperation: false,
83+
});
84+
});
85+
});
86+
});

0 commit comments

Comments
 (0)