Skip to content

feat(payments-next): Add eligibility check for free trials#20190

Merged
david1alvarez merged 1 commit intomainfrom
PAY-3554
Mar 19, 2026
Merged

feat(payments-next): Add eligibility check for free trials#20190
david1alvarez merged 1 commit intomainfrom
PAY-3554

Conversation

@david1alvarez
Copy link
Contributor

Because:

  • Free trial eligibility is a complex check that requires data from several locations

This commit:

  • Adds in the free trial repository and manager to handle requests for free trial records
  • Adds an eligibility checking method to the checkout service
  • Adds a free trial firestore collection
  • Adds in configs and tests to support the changes

Closes #PAY-3554

Checklist

Put an x in the boxes that apply

  • My commit is GPG signed.
  • If applicable, I have modified or added tests which pass locally.
  • I have added necessary documentation (if appropriate).
  • I have verified that my changes render correctly in RTL (if appropriate).

Screenshots (Optional)

Please attach the screenshots of the changes made in case of change in user interface.

Other information (Optional)

Any other information that is important to this pull request.

@david1alvarez david1alvarez requested a review from a team as a code owner March 13, 2026 20:06
@david1alvarez david1alvarez force-pushed the PAY-3554 branch 3 times, most recently from 47629bd to d05fc34 Compare March 16, 2026 21:26

export class FreeTrialConfig {
@IsString()
public readonly collectionName!: string;
Copy link
Member

Choose a reason for hiding this comment

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

Can we rename this to firestoreCollectionName

return this.firestore.collection(this.config.collectionName);
}

async getRecord(
Copy link
Member

Choose a reason for hiding this comment

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

This should be removed as a method (the repository handles this type of operation)

return getFreeTrialRecordData(this.collectionRef, uid, freeTrialConfigId);
}

async upsertRecord(uid: string, freeTrialConfigId: string): Promise<void> {
Copy link
Member

@julianpoy julianpoy Mar 16, 2026

Choose a reason for hiding this comment

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

This should be named something more akin to "recordFreeTrial" or "consumeFreeTrialForUser" or "startFreeTrialForUid". The way this is named currently is more akin to a repository record, rather than a manager method.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like "recordFreeTrial" for this.

});
}

async isCooldownElapsed(
Copy link
Member

Choose a reason for hiding this comment

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

Should this be named something more akin to isEligible since this doesn't throw when the user has no cooldown at all?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

isEligible I think is not specific enough, as this really only checks against the firestore records and the users free trial eligibility is more complex than that. Maybe getFirestoreEligibility? We could also invert it, and call it usedRecently or isBlockedByCooldown.

Copy link
Member

Choose a reason for hiding this comment

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

isBlockedByCooldown sounds perfect to me!

Comment on lines +12 to +26
const existingRecord = await db
.where('uid', '==', data.uid)
.where('freeTrialConfigId', '==', data.freeTrialConfigId)
.limit(1)
.get();

if (!existingRecord.empty) {
const doc = existingRecord.docs[0];
await doc.ref.update({ startedAt: data.startedAt });
} else {
await db.add({
uid: data.uid,
freeTrialConfigId: data.freeTrialConfigId,
startedAt: data.startedAt,
});
Copy link
Member

Choose a reason for hiding this comment

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

This upsert is vulnerable to a race condition between reading the existing record and adding a new record. If two calls for upsertFreeTrialRecord occur in reasonably quick succession (within ~70ms of each other in prod, given our Firestore latency), a duplicate will be created.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Looking into it more, it seems like Firestore doesn't really seem to have much in the way of preventative measures here. The main option I see is to plan around there being multiple records, and only serving up the latest record when queried. Firestore should be able to handle the number of records we'd be creating this way.

I'm planning to refactor this into getLatestFreeTrialRecordData and createFreeTrialRecord. Any suggestions for other approaches?

@david1alvarez david1alvarez force-pushed the PAY-3554 branch 3 times, most recently from c689b1c to 59c9862 Compare March 18, 2026 17:00
Because:

* Free trial eligibility is a complex check that requires data from several locations

This commit:

* Adds in the free trial repository and manager to handle requests for free trial records
* Adds an eligibility checking method to the checkout service
* Adds a free trial firestore collection
* Adds in configs and tests to support the changes
* Updates a test that was outdated and failing

Closes #PAY-3554
Copilot AI review requested due to automatic review settings March 19, 2026 17:09
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds infrastructure to persist free-trial usage in Firestore and introduces a checkout-level eligibility check gated by a Nimbus feature flag, so checkout can determine whether a user can receive a free trial based on config + cooldown history.

Changes:

  • Introduces FreeTrialConfig, FreeTrialManager, Firestore repository/types, and related unit tests.
  • Adds CheckoutService.getFreeTrialEligibility(...) (Nimbus-gated + Strapi-config-driven + cooldown check).
  • Wires FreeTrialManager and config into the Payments Next Nest app and environment config.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
libs/payments/ui/src/lib/nestapp/config.ts Adds freeTrialConfig to typed config schema.
libs/payments/ui/src/lib/nestapp/app.module.ts Registers FreeTrialManager in Nest providers.
libs/payments/cart/src/lib/free-trial.types.ts Defines Firestore record shape for free trials.
libs/payments/cart/src/lib/free-trial.repository.ts Adds insert + “latest record” query helpers.
libs/payments/cart/src/lib/free-trial.repository.spec.ts Unit tests for free-trial repository helpers.
libs/payments/cart/src/lib/free-trial.manager.ts Manager to record trials and enforce cooldown.
libs/payments/cart/src/lib/free-trial.manager.spec.ts Unit tests for cooldown logic and record creation.
libs/payments/cart/src/lib/free-trial.config.ts Adds config class + mock provider for tests.
libs/payments/cart/src/lib/checkout.service.ts Adds getFreeTrialEligibility and injects manager + Nimbus.
libs/payments/cart/src/lib/checkout.service.spec.ts Adds tests for free-trial eligibility and adjusts a zero-amount invoice test.
libs/payments/cart/src/lib/cart.service.spec.ts Updates DI setup to include free-trial providers.
libs/payments/cart/src/index.ts Exports new free-trial config/manager/types.
apps/payments/next/.env Adds env var for free-trial Firestore collection name.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +887 to +928
const [nimbusResult, freeTrialUtil] = await Promise.all([
this.nimbusManager.fetchExperiments({
nimbusUserId: this.nimbusManager.generateNimbusId(uid),
preview: false,
}),
this.productConfigurationManager.getFreeTrial(offeringConfigId),
]);

if (
!nimbusResult ||
!nimbusResult.Features['free-trial-feature'].enabled
) {
return null;
}

const freeTrials = freeTrialUtil.getResult();
if (!freeTrials) {
return null;
}

const matchingTrial = freeTrials.find(
(trial) =>
trial.trialLengthDays > 0 &&
trial.countries.includes(countryCode) &&
trial.intervals.includes(interval)
);

if (!matchingTrial) {
return null;
}

const isBlockedByCooldown = await this.freeTrialManager.isBlockedByCooldown(
uid,
matchingTrial.internalName,
matchingTrial.cooldownPeriodMonths
);

if (isBlockedByCooldown) {
return null;
}

return matchingTrial;
Comment on lines +836 to 845
await expect(
checkoutService.payWithStripe(
mockCart,
mockConfirmationToken.id,
mockAttributionData,
mockRequestArgs,
mockCart.uid
)
).resolves;
).resolves.not.toThrow();
});
@david1alvarez david1alvarez merged commit 0ce2304 into main Mar 19, 2026
25 checks passed
@david1alvarez david1alvarez deleted the PAY-3554 branch March 19, 2026 18:49
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.

3 participants