diff --git a/packages/payload/packages/payments/package.json b/packages/payload/packages/payments/package.json index cfd1763c8..6a7ad8cc5 100644 --- a/packages/payload/packages/payments/package.json +++ b/packages/payload/packages/payments/package.json @@ -41,6 +41,7 @@ "@xyo-network/id-payload-plugin": "^3.1.11", "@xyo-network/module-model": "^3.1.11", "@xyo-network/payload-model": "^3.1.11", + "@xyo-network/schema-payload-plugin": "^3.1.11", "@xyo-network/xns-record-payload-plugins": "workspace:^" }, "devDependencies": { diff --git a/packages/payload/packages/payments/src/Discount/Payload/Coupon/types/Condition.ts b/packages/payload/packages/payments/src/Discount/Payload/Coupon/types/Condition.ts new file mode 100644 index 000000000..aa99b0c5e --- /dev/null +++ b/packages/payload/packages/payments/src/Discount/Payload/Coupon/types/Condition.ts @@ -0,0 +1,24 @@ +import { + isPayloadOfSchemaType, isPayloadOfSchemaTypeWithMeta, isPayloadOfSchemaTypeWithSources, type WithMeta, type WithOptionalMeta, +} from '@xyo-network/payload-model' +import { type SchemaPayload, SchemaSchema } from '@xyo-network/schema-payload-plugin' + +/** + * The payloads that can be used as conditions for a coupon + */ +export type Condition = SchemaPayload | WithOptionalMeta | WithMeta + +/** + * Identity function for determining if an object is a Condition payload + */ +export const isCondition = isPayloadOfSchemaType(SchemaSchema) + +/** + * Identity function for determining if an object is a Condition payload with sources + */ +export const isConditionWithSources = isPayloadOfSchemaTypeWithSources(SchemaSchema) + +/** + * Identity function for determining if an object is a Condition payload with meta + */ +export const isConditionWithMeta = isPayloadOfSchemaTypeWithMeta(SchemaSchema) diff --git a/packages/payload/packages/payments/src/Discount/Payload/Coupon/types/index.ts b/packages/payload/packages/payments/src/Discount/Payload/Coupon/types/index.ts index ee5290191..9b502780a 100644 --- a/packages/payload/packages/payments/src/Discount/Payload/Coupon/types/index.ts +++ b/packages/payload/packages/payments/src/Discount/Payload/Coupon/types/index.ts @@ -1,2 +1,3 @@ +export * from './Condition.ts' export * from './CouponFields.ts' export * from './isStackable.ts' diff --git a/packages/payloadset/packages/payments/src/Discount/Diviner.ts b/packages/payloadset/packages/payments/src/Discount/Diviner.ts index f8895e40a..43dc390ce 100644 --- a/packages/payloadset/packages/payments/src/Discount/Diviner.ts +++ b/packages/payloadset/packages/payments/src/Discount/Diviner.ts @@ -15,14 +15,15 @@ import { creatableModule } from '@xyo-network/module-model' import { PayloadBuilder } from '@xyo-network/payload-builder' import { Payload } from '@xyo-network/payload-model' import { + Condition, Coupon, Discount, - EscrowTerms, isCoupon, + EscrowTerms, isConditionWithMeta, isCoupon, isCouponWithMeta, isEscrowTerms, NO_DISCOUNT, PaymentDiscountDivinerConfigSchema, PaymentDiscountDivinerParams, } from '@xyo-network/payment-payload-plugins' -import { applyCoupons } from './lib/index.ts' +import { applyCoupons, areConditionsFulfilled } from './lib/index.ts' const DEFAULT_BOUND_WITNESS_DIVINER_QUERY_PROPS: Readonly = { limit: 1, @@ -70,13 +71,17 @@ export class PaymentDiscountDiviner< if (appraisals.length !== termsAppraisals.length) return [{ ...NO_DISCOUNT, sources }] as TOut[] // Parse coupons - const coupons = await this.getEscrowDiscounts(terms, hashMap) + const [coupons, conditions] = await this.getEscrowDiscounts(terms, hashMap) // Add the coupons that were found to the sources // NOTE: Should we throw if not all coupons are found? const couponHashes = await PayloadBuilder.hashes(coupons) sources.push(...couponHashes) - const validCoupons = await this.filterToSigned(coupons.filter(this.isCouponCurrent)) + const validCoupons = await this.filterToSigned( + coupons + .filter(this.isCouponCurrent) + .filter(coupon => areConditionsFulfilled(coupon, conditions, payloads)), + ) // NOTE: Should we throw if not all coupons are valid? if (validCoupons.length === 0) return [{ ...NO_DISCOUNT, sources }] as TOut[] @@ -140,33 +145,56 @@ export class PaymentDiscountDiviner< * Finds the discounts specified by the escrow terms from the supplied payloads * @param terms The escrow terms * @param hashMap The payloads to search for the discounts - * @returns The discounts found in the payloads + * @returns A tuple containing all the escrow coupons and conditions referenced in those coupons + * that were found in the either the supplied payloads or the archivist */ - protected async getEscrowDiscounts(terms: EscrowTerms, hashMap: Record): Promise { + protected async getEscrowDiscounts(terms: EscrowTerms, hashMap: Record): Promise<[Coupon[], Condition[]]> { // Parse discounts - const hashes = terms.discounts ?? [] - if (hashes.length === 0) return [] + const discountsHashes = terms.discounts ?? [] + if (discountsHashes.length === 0) return [[], []] // Use the supplied payloads to find the discounts - const discounts: Coupon[] = hashes.map(hash => hashMap[hash]).filter(exists).filter(isCoupon) - const missing = hashes.filter(hash => !hashMap[hash]) + const discounts: Coupon[] = discountsHashes.map(hash => hashMap[hash]).filter(exists).filter(isCoupon) + const missingDiscounts = discountsHashes.filter(hash => !hashMap[hash]) // If not all discounts are found - if (missing.length > 0) { + if (missingDiscounts.length > 0) { // Find any remaining from discounts archivist const discountsArchivist = await this.getDiscountsArchivist() - const payloads = await discountsArchivist.get(missing) + const payloads = await discountsArchivist.get(missingDiscounts) discounts.push(...payloads.filter(isCouponWithMeta)) } // If not all discounts are found - if (discounts.length !== hashes.length) { + if (discounts.length !== discountsHashes.length) { const termsHash = await PayloadBuilder.hash(terms) const foundHashes = await PayloadBuilder.hashes(discounts) // Log individual discounts that were not found - for (const hash of hashes) { + for (const hash of discountsHashes) { if (!foundHashes.includes(hash)) console.warn(`Discount ${hash} not found for terms ${termsHash}`) } } - return discounts + + const conditionsHashes: Hash[] = discounts.flatMap(discount => discount.conditions ?? []) + const conditions: Condition[] = conditionsHashes.map(hash => hashMap[hash]).filter(exists).filter(isConditionWithMeta) + const missingConditions = conditionsHashes.filter(hash => !hashMap[hash]) + + // If not all conditions are found + if (missingConditions.length > 0) { + // Find any remaining from discounts archivist + const discountsArchivist = await this.getDiscountsArchivist() + const payloads = await discountsArchivist.get(missingConditions) + conditions.push(...payloads.filter(isConditionWithMeta)) + } + // If not all conditions are found + if (conditions.length !== conditionsHashes.length) { + const termsHash = await PayloadBuilder.hash(terms) + const foundHashes = await PayloadBuilder.hashes(conditions) + // Log individual conditions that were not found + for (const hash of discountsHashes) { + if (!foundHashes.includes(hash)) console.warn(`Coupon condition ${hash} not found for terms ${termsHash}`) + } + } + + return [discounts, conditions] } protected isCouponCurrent(coupon: Coupon): boolean { diff --git a/packages/payloadset/packages/payments/src/Discount/lib/findUnfulfilledConditions.ts b/packages/payloadset/packages/payments/src/Discount/lib/findUnfulfilledConditions.ts index 6439f300a..598d12188 100644 --- a/packages/payloadset/packages/payments/src/Discount/lib/findUnfulfilledConditions.ts +++ b/packages/payloadset/packages/payments/src/Discount/lib/findUnfulfilledConditions.ts @@ -1,8 +1,9 @@ import type { Hash } from '@xylabs/hex' import { PayloadBuilder } from '@xyo-network/payload-builder' -import type { Payload, WithMeta } from '@xyo-network/payload-model' -import type { Coupon } from '@xyo-network/payment-payload-plugins' -import type { SchemaPayload } from '@xyo-network/schema-payload-plugin' +import type { Payload } from '@xyo-network/payload-model' +import { + type Condition, type Coupon, isCondition, +} from '@xyo-network/payment-payload-plugins' import { isSchemaPayloadWithMeta } from '@xyo-network/schema-payload-plugin' import type { ValidateFunction } from 'ajv' import { Ajv } from 'ajv' @@ -11,25 +12,32 @@ import { Ajv } from 'ajv' const ajv = new Ajv({ strict: false }) // Create the Ajv instance once const schemaCache = new Map() // Cache to store compiled validators -export const areConditionsFulfilled = async (coupon: Coupon, payloads: Payload[]): Promise => - (await findUnfulfilledConditions(coupon, payloads)).length === 0 +/** + * Validates the conditions of a coupon against the provided payloads + * @param coupon The coupon to check + * @param conditions The conditions associated with the coupon + * @param payloads The associated payloads (containing the conditions and data to validate the conditions against) + * @returns True if all conditions are fulfilled, false otherwise + */ +export const areConditionsFulfilled = async (coupon: Coupon, conditions: Condition[] = [], payloads: Payload[] = []): Promise => + (await findUnfulfilledConditions(coupon, conditions, payloads)).length === 0 -// TODO: Should we separate conditions and payloads to prevent conflating data and "operands" (schemas to validate against data)? /** * Validates the conditions of a coupon against the provided payloads * @param coupon The coupon to check + * @param conditions The conditions associated with the coupon * @param payloads The associated payloads (containing the conditions and data to validate the conditions against) * @returns The unfulfilled condition hashes */ -export const findUnfulfilledConditions = async (coupon: Coupon, payloads: Payload[]): Promise => { +export const findUnfulfilledConditions = async (coupon: Coupon, conditions: Condition[] = [], payloads: Payload[] = []): Promise => { const unfulfilledConditions: Hash[] = [] // If there are no conditions, then they are fulfilled if (!coupon.conditions || coupon.conditions.length === 0) return unfulfilledConditions - const hashMap = await PayloadBuilder.toAllHashMap(payloads) + const hashMap = await PayloadBuilder.toAllHashMap([...conditions, ...payloads]) // Find all the conditions - const conditions = coupon.conditions.map(hash => hashMap[hash]).filter(isSchemaPayloadWithMeta) as WithMeta[] + const foundConditions = coupon.conditions.map(hash => hashMap[hash]).filter(isCondition) // Not all conditions were found - if (conditions.length !== coupon.conditions.length) { + if (foundConditions.length !== coupon.conditions.length) { const missing = coupon.conditions.filter(hash => !hashMap[hash]) unfulfilledConditions.push(...missing) return unfulfilledConditions diff --git a/packages/payloadset/packages/payments/src/Discount/lib/spec/findUnfulfilledConditions.spec.ts b/packages/payloadset/packages/payments/src/Discount/lib/spec/findUnfulfilledConditions.spec.ts index c2e215c3c..d4f16c637 100644 --- a/packages/payloadset/packages/payments/src/Discount/lib/spec/findUnfulfilledConditions.spec.ts +++ b/packages/payloadset/packages/payments/src/Discount/lib/spec/findUnfulfilledConditions.spec.ts @@ -130,7 +130,7 @@ describe('findUnfulfilledConditions', () => { const coupon: Coupon = { ...validCoupon, conditions } const terms: EscrowTerms = { ...baseTerms, discounts: [await PayloadBuilder.dataHash(coupon)] } const payloads = [terms, coupon, rule, ...assets, ...appraisals] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual([]) }) it('for multiple conditions', async () => { @@ -138,7 +138,7 @@ describe('findUnfulfilledConditions', () => { const coupon: Coupon = { ...validCoupon, conditions } const terms: EscrowTerms = { ...baseTerms, discounts: [await PayloadBuilder.dataHash(coupon)] } const payloads = [terms, coupon, ...allConditions, ...assets, ...appraisals] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, allConditions, payloads) expect(results).toEqual([]) }) }) @@ -151,7 +151,7 @@ describe('findUnfulfilledConditions', () => { const conditions = [await PayloadBuilder.dataHash(rule)] const coupon: Coupon = { ...validCoupon, conditions } const payloads = [coupon, rule, ...assets, ...appraisals] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) it('when escrow terms appraisals do not exist', async () => { @@ -161,7 +161,7 @@ describe('findUnfulfilledConditions', () => { ...baseTerms, discounts: [await PayloadBuilder.dataHash(coupon)], appraisals: undefined, } const payloads = [terms, coupon, rule, ...appraisals, ...assets] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) it('when escrow terms appraisals is empty', async () => { @@ -171,7 +171,7 @@ describe('findUnfulfilledConditions', () => { ...baseTerms, discounts: [await PayloadBuilder.dataHash(coupon)], appraisals: [], } const payloads = [terms, coupon, rule, ...appraisals, ...assets] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) it('when appraisals not supplied', async () => { @@ -179,7 +179,7 @@ describe('findUnfulfilledConditions', () => { const coupon: Coupon = { ...validCoupon, conditions } const terms: EscrowTerms = { ...baseTerms, discounts: [await PayloadBuilder.dataHash(coupon)] } const payloads = [terms, coupon, rule, ...assets] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) it('when supplied appraisal price exceeds the maximum amount', async () => { @@ -197,7 +197,7 @@ describe('findUnfulfilledConditions', () => { appraisals: await PayloadBuilder.dataHashes(appraisals), } const payloads = [terms, coupon, rule, ...assets, ...appraisals] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) }) @@ -207,7 +207,7 @@ describe('findUnfulfilledConditions', () => { const conditions = [await PayloadBuilder.dataHash(rule)] const coupon: Coupon = { ...validCoupon, conditions } const payloads = [coupon, rule, ...assets, ...appraisals] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) it('when escrow terms assets do not exist', async () => { @@ -217,7 +217,7 @@ describe('findUnfulfilledConditions', () => { ...baseTerms, discounts: [await PayloadBuilder.dataHash(coupon)], assets: undefined, } const payloads = [terms, coupon, rule, ...appraisals, ...assets] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) it('when escrow terms assets is empty', async () => { @@ -227,7 +227,7 @@ describe('findUnfulfilledConditions', () => { ...baseTerms, discounts: [await PayloadBuilder.dataHash(coupon)], assets: [], } const payloads = [terms, coupon, rule, ...appraisals, ...assets] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) it('when escrow terms assets quantity does not exceed the required amount', async () => { @@ -238,7 +238,7 @@ describe('findUnfulfilledConditions', () => { ...baseTerms, discounts: [await PayloadBuilder.dataHash(coupon)], assets: await PayloadBuilder.dataHashes(assets), } const payloads = [terms, coupon, rule, ...assets, ...appraisals] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) }) @@ -248,7 +248,7 @@ describe('findUnfulfilledConditions', () => { const conditions = [await PayloadBuilder.dataHash(rule)] const coupon: Coupon = { ...validCoupon, conditions } const payloads = [coupon, rule, ...assets, ...appraisals] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) it('when escrow terms buyer does not exist', async () => { @@ -258,7 +258,7 @@ describe('findUnfulfilledConditions', () => { ...baseTerms, discounts: [await PayloadBuilder.dataHash(coupon)], buyer: undefined, } const payloads = [terms, coupon, rule, ...assets, ...appraisals] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) it('when escrow terms buyers is empty', async () => { @@ -268,7 +268,7 @@ describe('findUnfulfilledConditions', () => { ...baseTerms, discounts: [await PayloadBuilder.dataHash(coupon)], buyer: [], } const payloads = [terms, coupon, rule, ...assets, ...appraisals] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) it('when escrow terms buyer does not contain specified address', async () => { @@ -285,7 +285,7 @@ describe('findUnfulfilledConditions', () => { buyer: [buyer.address], } const payloads = [terms, coupon, rule, ...assets, ...appraisals] - const results = await findUnfulfilledConditions(coupon, payloads) + const results = await findUnfulfilledConditions(coupon, [rule], payloads) expect(results).toEqual(conditions) }) }) diff --git a/yarn.lock b/yarn.lock index 3df01608b..894ed7ce8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7094,6 +7094,7 @@ __metadata: "@xyo-network/id-payload-plugin": "npm:^3.1.11" "@xyo-network/module-model": "npm:^3.1.11" "@xyo-network/payload-model": "npm:^3.1.11" + "@xyo-network/schema-payload-plugin": "npm:^3.1.11" "@xyo-network/xns-record-payload-plugins": "workspace:^" typescript: "npm:^5.5.4" languageName: unknown