Skip to content

Commit 910ac27

Browse files
committed
refactor(billing): enhance CloudPayments webhook handling and improve documentation for payment processing and subscription renewals
1 parent 02d13a2 commit 910ac27

4 files changed

Lines changed: 74 additions & 18 deletions

File tree

src/billing/cloudpayments.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,15 @@ export default class CloudPaymentsWebhooks {
102102
}
103103

104104
/**
105-
* Route to confirm the correctness of a user's payment
105+
* Route to confirm that CloudPayments may process the payment.
106106
* https://developers.cloudpayments.ru/#check
107107
*
108+
* This route handles both the first widget payment and later subscription charges.
109+
* The first widget payment sends signed Data with billing intent and optional promo.
110+
* Later recurrent charges may arrive without Data; in that case we resolve the
111+
* workspace and current plan by SubscriptionId and validate the amount against
112+
* the full plan price.
113+
*
108114
* @param req - cloudpayments request with payment details
109115
* @param res - check result code
110116
*/
@@ -642,9 +648,15 @@ subscription id: ${body.SubscriptionId}`;
642648
}
643649

644650
/**
645-
* Route is executed if the status of the recurring payment subscription has been changed.
651+
* Route executed when a CloudPayments subscription status changes.
646652
* https://developers.cloudpayments.ru/#recurrent
647653
*
654+
* This notification is about the subscription entity, not a replacement for
655+
* /check or /pay transaction notifications. CloudPayments identifies which
656+
* charge number this is via SuccessfulTransactionsNumber and sends the
657+
* subscription Id; our transaction handlers use that Id to find the workspace.
658+
* Promo data is not expected here and is not applied to subscription renewals.
659+
*
648660
* @param req - cloudpayments request with subscription details
649661
* @param res - result code
650662
*/
@@ -830,7 +842,17 @@ status: ${body.Status}`
830842
}
831843

832844
/**
833-
* Parses request body and returns data from it
845+
* Parses CloudPayments request body into the signed billing intent.
846+
*
847+
* First widget payments include Data.checksum generated by composePayment; the
848+
* checksum is the only trusted source for workspaceId, tariffPlanId, userId,
849+
* shouldSaveCard, and promo id. Unsigned widget Data fields must not override it.
850+
*
851+
* Recurrent subscription renewals usually do not include Data. For those
852+
* requests CloudPayments sends SubscriptionId and AccountId, so we restore the
853+
* workspace and current plan by SubscriptionId. Because there is no signed promo
854+
* in this path, data.promo is intentionally absent: promo discounts are applied
855+
* only to the first widget payment, while renewals are charged at full plan price.
834856
*
835857
* @param req - request with necessary data
836858
*/

src/billing/types/paymentData.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ interface RecurrentPaymentSettings {
2020
startDate?: string;
2121

2222
/**
23-
* Recurring payment amount.
23+
* Recurring payment amount for automatic subscription renewals.
24+
* Keep it equal to the full plan price even when the first widget charge uses a promo.
2425
*/
2526
amount?: number;
2627
}
@@ -38,8 +39,9 @@ interface CloudPaymentsSettings {
3839
}
3940

4041
/**
41-
* Promo reference attached to payment request.
42+
* Promo reference attached to the signed first payment request.
4243
* Amounts are resolved on the server by promo id during check/pay.
44+
* Recurrent renewals are restored by SubscriptionId and do not carry promo data.
4345
*/
4446
export interface PaymentPromoData {
4547
/**

test/billing/cloudpayments.test.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ function createCheckBody(transactionId: number, amount: string, data: string) {
166166
};
167167
}
168168

169-
function createPayBody(transactionId: number, amount: string, data: string, overrides: Record<string, unknown> = {}) {
169+
function createPayBody(transactionId: number, amount: string, data?: string, overrides: Record<string, unknown> = {}) {
170170
return {
171171
TransactionId: transactionId,
172172
Amount: amount,
@@ -758,6 +758,37 @@ describe('CloudPaymentsWebhooks', () => {
758758
expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS });
759759
});
760760

761+
it('should complete subscription renewal without Data and without promo usage', async () => {
762+
const webhooks = new CloudPaymentsWebhooks() as any;
763+
const workspaceId = new ObjectId().toString();
764+
const userId = new ObjectId().toString();
765+
const plan = createPlan(1000);
766+
const { context, workspace, changePlan, createUsage } = createWebhookContext({
767+
workspaceId,
768+
userId,
769+
plan,
770+
subscriptionId: 'subscription-id',
771+
});
772+
773+
context.factories.workspacesFactory.findBySubscriptionId = jest.fn().mockResolvedValue(workspace);
774+
775+
const res = createMockResponse();
776+
777+
await webhooks.pay({
778+
context,
779+
body: createPayBody(2004, '1000', undefined, {
780+
AccountId: userId,
781+
Data: undefined,
782+
SubscriptionId: 'subscription-id',
783+
}),
784+
}, res);
785+
786+
expect(context.factories.workspacesFactory.findBySubscriptionId).toHaveBeenCalledWith('subscription-id');
787+
expect(changePlan).toHaveBeenCalledWith(plan._id);
788+
expect(createUsage).not.toHaveBeenCalled();
789+
expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS });
790+
});
791+
761792
it('should cancel old subscription when a new subscription id is received', async () => {
762793
const webhooks = new CloudPaymentsWebhooks() as any;
763794
const workspaceId = new ObjectId().toString();
@@ -775,7 +806,7 @@ describe('CloudPaymentsWebhooks', () => {
775806

776807
await webhooks.pay({
777808
context,
778-
body: createPayBody(2004, '1000', Data, { SubscriptionId: 'new-subscription' }),
809+
body: createPayBody(2005, '1000', Data, { SubscriptionId: 'new-subscription' }),
779810
}, res);
780811

781812
expect(cloudPaymentsClientMocks.cancelSubscription).toHaveBeenCalledWith({ Id: 'old-subscription' });
@@ -807,10 +838,10 @@ describe('CloudPaymentsWebhooks', () => {
807838
},
808839
});
809840

810-
await webhooks.pay({ context, body: createPayBody(2005, '1', Data) }, res);
841+
await webhooks.pay({ context, body: createPayBody(2006, '1', Data) }, res);
811842

812843
expect(changePlan).not.toHaveBeenCalled();
813-
expect(cloudPaymentsApi.cancelPayment).toHaveBeenCalledWith(2005);
844+
expect(cloudPaymentsApi.cancelPayment).toHaveBeenCalledWith(2006);
814845
expect(createBusinessOperation).toHaveBeenCalledWith(expect.objectContaining({
815846
type: BusinessOperationType.CardLinkRefund,
816847
status: BusinessOperationStatus.Confirmed,
@@ -831,7 +862,7 @@ describe('CloudPaymentsWebhooks', () => {
831862

832863
(publish as jest.Mock).mockRejectedValueOnce(new Error('rabbit down'));
833864

834-
await webhooks.pay({ context, body: createPayBody(2006, '1000', Data) }, res);
865+
await webhooks.pay({ context, body: createPayBody(2007, '1000', Data) }, res);
835866

836867
expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS });
837868
});
@@ -848,7 +879,7 @@ describe('CloudPaymentsWebhooks', () => {
848879

849880
(sendNotification as jest.Mock).mockRejectedValueOnce(new Error('notify failed'));
850881

851-
await webhooks.pay({ context, body: createPayBody(2007, '1000', Data) }, res);
882+
await webhooks.pay({ context, body: createPayBody(2008, '1000', Data) }, res);
852883

853884
expect(res.json).toHaveBeenCalledWith({ code: PayCodes.SUCCESS });
854885
});

test/integration/cases/billing/promo.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,19 @@ describe('Promo billing webhooks', () => {
5858
* Insert a document and load the persisted record.
5959
* Throws if insert or read fails — keeps fixture setup explicit in tests.
6060
*/
61-
async function insertAndLoad<T extends { _id?: ObjectId }, R extends T>(
62-
collection: Collection<T>,
63-
document: Omit<T, '_id'>,
61+
async function insertAndLoad<R extends { _id: ObjectId }>(
62+
collection: Collection<any>,
63+
document: Omit<R, '_id'>,
6464
errorMessage: string
6565
): Promise<R> {
66-
const insertedId = (await collection.insertOne(document as T)).insertedId;
67-
const result = await collection.findOne({ _id: insertedId } as Partial<T>);
66+
const insertedId = (await collection.insertOne(document)).insertedId;
67+
const result = await collection.findOne({ _id: insertedId });
6868

6969
if (!result) {
7070
throw new Error(errorMessage);
7171
}
7272

73-
return result as R;
73+
return result as unknown as R;
7474
}
7575

7676
/**
@@ -95,7 +95,7 @@ describe('Promo billing webhooks', () => {
9595
name: 'PromoBillingTest',
9696
accountId: '123',
9797
tariffPlanId: currentPlanId,
98-
} as WorkspaceDBScheme,
98+
} as Omit<WorkspaceDBScheme, '_id'>,
9999
'Failed to create workspace'
100100
);
101101

@@ -241,6 +241,7 @@ describe('Promo billing webhooks', () => {
241241
OperationType: OperationType.PAYMENT,
242242
Status: OperationStatus.COMPLETED,
243243
TestMode: false,
244+
TotalFee: 0,
244245
TransactionId: transactionId,
245246
Token: '123123',
246247
IssuerBankCountry: 'US',

0 commit comments

Comments
 (0)