feat(payments-next): Add eligibility check for free trials#20190
feat(payments-next): Add eligibility check for free trials#20190david1alvarez merged 1 commit intomainfrom
Conversation
47629bd to
d05fc34
Compare
|
|
||
| export class FreeTrialConfig { | ||
| @IsString() | ||
| public readonly collectionName!: string; |
There was a problem hiding this comment.
Can we rename this to firestoreCollectionName
| return this.firestore.collection(this.config.collectionName); | ||
| } | ||
|
|
||
| async getRecord( |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I like "recordFreeTrial" for this.
| }); | ||
| } | ||
|
|
||
| async isCooldownElapsed( |
There was a problem hiding this comment.
Should this be named something more akin to isEligible since this doesn't throw when the user has no cooldown at all?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
isBlockedByCooldown sounds perfect to me!
| 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, | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
c689b1c to
59c9862
Compare
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
There was a problem hiding this comment.
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
FreeTrialManagerand 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.
| 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; |
| await expect( | ||
| checkoutService.payWithStripe( | ||
| mockCart, | ||
| mockConfirmationToken.id, | ||
| mockAttributionData, | ||
| mockRequestArgs, | ||
| mockCart.uid | ||
| ) | ||
| ).resolves; | ||
| ).resolves.not.toThrow(); | ||
| }); |
Because:
This commit:
Closes #PAY-3554
Checklist
Put an
xin the boxes that applyScreenshots (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.