Skip to content

Add promo code support to billing API - #685

Open
alisawavezen12 wants to merge 13 commits into
masterfrom
feat/promocodes
Open

alisawavezen12 wants to merge 13 commits into
masterfrom
feat/promocodes

Conversation

@alisawavezen12

@alisawavezen12 alisawavezen12 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Add promo code validation, plan restrictions, and discounted payment amounts. Reserve promo codes for payments and handle successful and failed payment callbacks.

Related changes in Web: codex-team/hawk.garage#927

Comment on lines +66 to +89
/**
* Signed amount of the current promo payment in rubles
*/
paymentAmount?: number;
/**
* Signed promo payment attempt, also used as the business operation ID.
*/
promoPaymentId?: string;
/**
* Signed identifier of the promo code applied to this payment.
*/
promoCodeId?: string;
/**
* Signed full plan price in rubles before the discount.
*/
originalAmount?: number;
/**
* Signed percentage discount captured when the quote was prepared.
*/
discountPercent?: number;
/**
* Signed start date for future full-price payments
*/
nextPaymentDate?: string;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If these fields related to the new promo codes feature, it's better to wrap them to the promo wrapper.

If they are used for regular payments as well, it should be explained, why they was added.

Comment thread src/billing/cloudpayments.ts Outdated
Comment on lines +890 to +899
const promoCode = await db.collection('promoCodes').findOne({ _id: new ObjectId(payment.promoCodeId) });

if (!promoCode) {
throw new Error('PROMO_CODE_INVALID');
}

await findActivePromoCode(db, promoCode.value, plan._id);
await checkPromoCodeUsage(db, promoCode._id, new ObjectId(data.userId), workspace._id, payment.transactionId);

await db.collection('businessOperations').updateOne({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's strange that one level contains both request/response handling, business logic and db queries.

As i can see, we don't use such a code organization pattern in other methods in this file.

Comment thread src/billing/cloudpayments.ts Outdated
* @param payment - validated promo payment
* @returns matching business operation
*/
private async getPromoBusinessOperation(req: express.Request, payment: PromoPayment): Promise<BusinessOperationModel> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not sure it's a good to pass the whole request here. Pass exact arguments instead.

Comment thread src/billing/cloudpayments.ts Outdated
* @param data - verified payment data
* @param succeeded - whether the provider reported a successful payment
*/
private async finishPromoPayment(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method seems to be too big. It also mixes request/response, db queries, etc. Consider how to make it more understandable.

Comment thread src/billing/cloudpayments.ts Outdated
}

/**
* Retry cancellation without touching the subscription selected by a newer payment.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Description is not clear. Describe the use case (shortly).

Comment thread src/billing/cloudpayments.ts Outdated
}

/**
* Renew callback ownership and fence writes from an expired handler.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

@neSpecc neSpecc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, the promo logic itself mostly works and is covered by tests, but a few things need to change before merging.

1. The PR changes the regular payment flow, not only promo. Regular /pay and /recurrent were rewritten (see the inline comments). This is the main risk, so please revert them to the master logic. If regular payments need idempotency or handling of out-of-order callbacks, let's do that in a separate PR with its own tests. The existing integration tests in test/integration/cases/billing pass, but they don't cover subscription replacement and cancellation, so green CI doesn't prove the regular flow is safe.

2. Suggested shape of the feature. A promo code should only change the price and reuse the existing flow:

  • composePayment: get a quote from a promo code service and put promo: { ... } into the checksum;
  • /check: expectedAmount = data.promo ? data.promo.finalAmount : plan.monthlyCharge, plus a check that the code can still be used. The business operation is created through the factory as usual;
  • /pay: the regular flow plus if (data.promo) redeem(...) (safe to repeat thanks to the unique index). The receipt uses the amount actually paid;
  • /fail: no changes.

DB access should go through PromoCodesFactory / PromoCodeRedemptionsFactory, like the other models, and domain functions shouldn't receive req / res. This removes finishPromoPayment and its own lease mechanism, which duplicates /pay and /fail (plan change, unblock, notifications, receipt, card saving) and will drift from them with every change.

3. payWithCard ignores the promo code. It charges the full price, so /check rejects the payment with WRONG_AMOUNT. Garage doesn't call it now, but let's explicitly reject promo checksums there.

Comment thread src/billing/cloudpayments.ts Outdated
Comment on lines +298 to +308
if (!businessOperation._id) {
throw new Error('Business operation has no id');
}

const subscriptionId = body.SubscriptionId;

/**
* Cancellation of the current subscription if:
* 1) the user pays manually (the workspace has an active subscription, but the request body does not)
* 2) if payment is made for another subscription (subscriptions id are not equal)
*/
if (workspace.subscriptionId) {
if (!subscriptionId || subscriptionId !== workspace.subscriptionId) {
await this.clientApi.cancelSubscription({
Id: workspace.subscriptionId,
});
}
}

if (subscriptionId) {
await workspace.setSubscriptionId(subscriptionId);
}
await applyPaidPlan(
this.getPromoDatabase(), workspace._id, tariffPlan._id,
businessOperation._id, businessOperation.dtCreated,
body.SubscriptionId || null, data.isCardLinkOperation
);
await this.cancelSupersededSubscriptions(workspace._id);
await businessOperation.setStatus(BusinessOperationStatus.Confirmed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the regular (non-promo) payment flow. workspace.changePlan() and the subscription cancel/replace logic are replaced with applyPaidPlan() from promoCodes/, and every payment now writes new workspace fields (billingPaymentId, billingPaymentDate, cardLinkPaymentId, cardLinkPaymentDate, subscriptionIdsToCancel) that are not in WorkspaceDBScheme.

Also, the result of applyPaidPlan() is ignored. If its conditions don't match (e.g. the workspace lastChargeDate is later than the operation's dtCreated), it updates nothing and returns false, but we still confirm the operation, unblock the workspace and tell the user the payment succeeded. On master the plan was always applied.

Please keep the master logic here.

Comment thread src/billing/cloudpayments.ts Outdated
const error = e as Error;

this.sendError(res, PayCodes.SUCCESS, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body);
this.sendError(res.status(500), PayCodes.SUCCESS, `[Billing / Pay] Can't update workspace billing data ${error.toString()}`, body);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTP 500 changes the retry behaviour for all regular payments: CloudPayments will start re-sending /pay. At the same time the body says code: 0 (success). If we want retries for regular payments, that should be a separate decision, not part of the promo feature.

Comment thread src/billing/cloudpayments.ts Outdated
Comment on lines +662 to +668
await this.getPromoDatabase().collection('workspaces')
.updateOne({
_id: workspace._id,
subscriptionId: body.Id,
}, {
$unset: { subscriptionId: '' },
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is regular subscription handling, unrelated to promo codes. It's now a raw DB query in the webhook layer instead of workspace.setSubscriptionId(null). If we need "unset only if the id still matches", let's add it to WorkspaceModel in a separate PR.

Comment thread src/billing/cloudpayments.ts Outdated
*
* @returns connected accounts database
*/
private getPromoDatabase(): Db {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name says "promo", but it returns the shared accounts DB and is also used by regular /pay and /recurrent. I'd remove it and work with collections through models/factories, like the rest of this file.

Comment thread src/billing/cloudpayments.ts Outdated
* @param data - parsed payment data
* @returns signed payment details
*/
private getPromoPayment(body: CheckRequest | PayRequest | FailRequest, data: PaymentData): PromoPayment {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a mapper rather than a getter, so buildPromoPayment would be clearer.

Comment thread src/billing/promoCodes/redemptions.ts Outdated
/**
* Signed discounted amount in rubles.
*/
paymentAmount: number;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

paymentAmount (checksum) and finalAmount (quote, redemption) are the same value under two names, and amount next to it is easy to confuse with them. Let's use one name, e.g. finalAmount, and call the callback value something like paidAmount.

Comment thread src/billing/promoCodes/redemptions.ts Outdated
Comment on lines +130 to +139
/**
* Check successful uses only. Parallel payments may both pass before either succeeds.
*
* @param db - accounts database
* @param promoCodeId - selected code
* @param userId - payer
* @param workspaceId - paid workspace
* @param transactionId - same successful callback retry, if present
*/
export async function checkPromoCodeUsage(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function throws when the code can't be used, so assertPromoCodeNotUsed / ensurePromoCodeAvailable would be clearer. The description talks about race conditions rather than what the function is for.

Comment thread src/billing/promoCodes/redemptions.ts Outdated

const maxUses = getMaxUses(promoCode.maxUses);

if (maxUses !== undefined && await redemptions.countDocuments({ promoCodeId }) + 1 > maxUses) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxUses is checked only before the payment and not atomically, so parallel payments can exceed the limit. If that's acceptable, let's make it an explicit product decision.

Comment thread src/billing/promoCodes/redemptions.ts Outdated
Comment on lines +178 to +185
/**
* Calculate a signed quote without writing a usage record.
*
* @param db - accounts database
* @param input - requested payment
* @returns discounted quote
*/
export async function preparePromoCode(db: Db, input: PromoCodeInput): Promise<PromoCodeQuote> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description says "signed quote", but the function doesn't sign anything: it calculates the discounted price. Something like calculatePromoQuote would be clearer.

Comment thread src/billing/promoCodes/redemptions.ts Outdated
Comment on lines +199 to +204
/**
* Validate the signed quote against the successful payment callback.
*
* @param payment - signed identity and provider callback fields
*/
export function validatePromoPayment(payment: PromoPayment): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description says "against the successful payment callback", but this is also called in /check.

@neSpecc neSpecc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, all previous comments are addressed. The regular /check, /pay, /fail and /recurrent flow now matches master apart from the separate data.promo blocks, and the promo logic goes through the factories.

A few things left, see inline. The main one is payload.userId in cloudpayments.ts, the rest are naming and descriptions.

amount: +body.Amount * PENNY_MULTIPLIER,
currency: body.Currency,
userId: member._id,
userId: data.promo ? member.userId : member._id,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now the same field means different things depending on the promo code: for regular payments it's the team membership _id (as in master), for promo payments it's the user id.

In this repo, userId of a plan purchase is read only by the new validatePromoOperation(). I'd keep member._id for all payments and drop the userId comparison there: transactionId, workspace, plan and amount are enough to match the operation, and the redemption already gets the user id from the signed checksum. If we want to store the real user id, let's change it for all payments in a separate PR.

*
* @param payment - verified signed quote and provider callback fields
*/
public async ensurePaymentAvailable(payment: PromoPayment): Promise<void> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's hard to tell from the name what "payment available" means, and the description explains how the method works rather than what it's for. Something like ensurePromoCodeStillValid() with "Re-check in /check that the paid amount matches the signed price and the code can still be used for this plan, payer and workspace".

Comment on lines +59 to +82
* Read legacy percentage documents without changing stored data.
*
* @param query - indexed code or identifier lookup
* @param tariffPlanId - selected plan
*/
private async findActivePromoCode(query: Filter<PromoCode>, tariffPlanId: ObjectId): Promise<PromoCode> {
const promoCode = await this.collection.findOne(query);

ensureActivePromoCode(promoCode, tariffPlanId);

/*
* shortcut: support discountPercent until existing documents have been migrated to discount.
*/
const legacy = promoCode as PromoCode & { discountPercent?: number };

if (promoCode.discount === undefined && typeof legacy.discountPercent === 'number') {
return {
...promoCode,
discount: {
type: 'percent',
value: legacy.discountPercent,
},
};
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The promoCodes collection is created by this PR, so production has no discountPercent documents. If there are some on stage, let's update them by hand and remove this shim.

Then the description can say what the method is for, e.g. "Find a promo code that can be applied to the selected plan, or throw".

* @param identity - payer and paid workspace
* @param transactionId - provider transaction on callback retries
*/
public async ensurePromoCodeAvailable(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method has the same name as the pure ensurePromoCodeAvailable() from promoCodes/redemptions.ts that it calls, so at a call site it's unclear which one is used. Maybe rename the pure one, e.g. validatePromoCodeUsage().

Also, the description says what the method doesn't do instead of what it's for. Something like "Throw if this payer or workspace has already used the code, or the code has reached maxUses".

@@ -0,0 +1,151 @@
import { ObjectId } from 'mongodb';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the refactoring this file contains promo payment types and validators, while redemptions live in PromoCodeRedemptionsFactory. A name like promoCodes/validation.ts would match the content better.

payload.workspaceId.toString() !== payment.workspaceId ||
payload.userId.toString() !== payment.userId ||
payload.tariffPlanId.toString() !== payment.tariffPlanId ||
payload.amount !== payment.paidAmount * 100 || payload.currency !== payment.currency) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use PENNY_MULTIPLIER instead of 100, like cloudpayments.ts does when it creates this operation (it would need to be moved to a shared place).

/**
* Retry a promo callback whose successful usage could not be persisted.
*/
RETRY = 1,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CloudPayments describes only code: 0 for Pay notifications, and the retry is triggered by the non-200 HTTP status, not by the code. Also, PayCodes is shared by all payments, while the description ties this value to promo codes.

I'd drop RETRY and keep res.status(500) with a comment at the call site in /pay.

promo?: PromoPaymentData;
/**
* Subscription start date signed by composePayment for plan purchases and card linking.
* Kept outside promo because regular payments use it too; promo /check also

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regular payments don't read nextPaymentDate: composePayment only signs it into the checksum, and the only reader is validatePromoSubscription(). Let's describe what the field is, e.g. "Start date of the subscription created after this payment", without the claim about regular payments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants