Skip to content

Commit

Permalink
Merge pull request #45 from XYOracleNetwork/feature/coupon-conditions…
Browse files Browse the repository at this point in the history
…-in-diviner

Coupon Conditions in Diviner
  • Loading branch information
JoelBCarter authored Sep 19, 2024
2 parents a2c9504 + 076403f commit b7ef864
Show file tree
Hide file tree
Showing 7 changed files with 103 additions and 40 deletions.
1 change: 1 addition & 0 deletions packages/payload/packages/payments/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SchemaPayload> | WithMeta<SchemaPayload>

/**
* Identity function for determining if an object is a Condition payload
*/
export const isCondition = isPayloadOfSchemaType<Condition>(SchemaSchema)

/**
* Identity function for determining if an object is a Condition payload with sources
*/
export const isConditionWithSources = isPayloadOfSchemaTypeWithSources<Condition>(SchemaSchema)

/**
* Identity function for determining if an object is a Condition payload with meta
*/
export const isConditionWithMeta = isPayloadOfSchemaTypeWithMeta<Condition>(SchemaSchema)
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './Condition.ts'
export * from './CouponFields.ts'
export * from './isStackable.ts'
58 changes: 43 additions & 15 deletions packages/payloadset/packages/payments/src/Discount/Diviner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BoundWitnessDivinerQueryPayload> = {
limit: 1,
Expand Down Expand Up @@ -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[]

Expand Down Expand Up @@ -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<Hash, Payload>): Promise<Coupon[]> {
protected async getEscrowDiscounts(terms: EscrowTerms, hashMap: Record<Hash, Payload>): 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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<boolean> =>
(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<boolean> =>
(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<Hash[]> => {
export const findUnfulfilledConditions = async (coupon: Coupon, conditions: Condition[] = [], payloads: Payload[] = []): Promise<Hash[]> => {
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<SchemaPayload>[]
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,15 @@ 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 () => {
const conditions = await PayloadBuilder.dataHashes(allConditions)
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([])
})
})
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -171,15 +171,15 @@ 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 () => {
const conditions = [await PayloadBuilder.dataHash(rule)]
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 () => {
Expand All @@ -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)
})
})
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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)
})
})
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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)
})
})
Expand Down
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit b7ef864

Please sign in to comment.