mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 21:27:07 +00:00
Add OpenABTest draft experiment and accounting contracts (#180)
This commit is contained in:
parent
123ba2e39a
commit
d8b3126782
25 changed files with 1410 additions and 12 deletions
22
packages/schemas/fixtures/openabtest/adjustment.json
Normal file
22
packages/schemas/fixtures/openabtest/adjustment.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"openabtest": "0.1-draft",
|
||||
"producerId": "chovy-server",
|
||||
"manifestId": "chovy-signup-discount-v1",
|
||||
"manifestRevision": 1,
|
||||
"participantId": "participant-opaque-7d12",
|
||||
"at": "2026-09-15T12:00:00Z",
|
||||
"id": "event-adjustment-42",
|
||||
"kind": "adjustment",
|
||||
"payload": {
|
||||
"assignmentId": "assignment-42",
|
||||
"variantId": "signup-10",
|
||||
"conversionId": "purchase-42",
|
||||
"adjustmentId": "refund-42",
|
||||
"currency": "USD",
|
||||
"amountMinor": -3600,
|
||||
"reason": "refund",
|
||||
"proofRefs": [
|
||||
"refund-proof-42"
|
||||
]
|
||||
}
|
||||
}
|
||||
15
packages/schemas/fixtures/openabtest/assignment.json
Normal file
15
packages/schemas/fixtures/openabtest/assignment.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"openabtest": "0.1-draft",
|
||||
"producerId": "chovy-server",
|
||||
"manifestId": "chovy-signup-discount-v1",
|
||||
"manifestRevision": 1,
|
||||
"participantId": "participant-opaque-7d12",
|
||||
"at": "2026-09-15T12:00:00Z",
|
||||
"id": "event-assignment-42",
|
||||
"kind": "assignment",
|
||||
"payload": {
|
||||
"assignmentId": "assignment-42",
|
||||
"variantId": "signup-10",
|
||||
"cohortId": "eligible-referred-customers"
|
||||
}
|
||||
}
|
||||
69
packages/schemas/fixtures/openabtest/chovy-manifest.json
Normal file
69
packages/schemas/fixtures/openabtest/chovy-manifest.json
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"openabtest": "0.1-draft",
|
||||
"id": "chovy-signup-discount-v1",
|
||||
"revision": 1,
|
||||
"name": "Chovy referred purchase discount",
|
||||
"state": "draft",
|
||||
"cohort": {
|
||||
"id": "eligible-referred-customers",
|
||||
"eligibilityRule": "Server-verified eligible referral; exclude self-referrals and invalid attribution. Every referred purchase qualifies.",
|
||||
"purchaseScope": "all-referred-purchases"
|
||||
},
|
||||
"assignment": {
|
||||
"unit": "user",
|
||||
"authority": "server",
|
||||
"keyVersion": "1",
|
||||
"algorithm": "hmac-sha256-persisted-v1",
|
||||
"persistence": "sticky"
|
||||
},
|
||||
"variants": [
|
||||
{
|
||||
"id": "signup-5",
|
||||
"weight": 1,
|
||||
"parameters": {
|
||||
"discountBps": 500
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "signup-10",
|
||||
"weight": 1,
|
||||
"parameters": {
|
||||
"discountBps": 1000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "signup-20",
|
||||
"weight": 1,
|
||||
"parameters": {
|
||||
"discountBps": 2000
|
||||
}
|
||||
}
|
||||
],
|
||||
"window": {
|
||||
"startsAt": "2026-09-14T00:00:00Z",
|
||||
"endsAt": "2026-10-14T00:00:00Z"
|
||||
},
|
||||
"metrics": {
|
||||
"primary": "retained-profit-per-eligible-visitor",
|
||||
"denominator": "unique-eligible-participants",
|
||||
"conversion": "unique-participants-with-confirmed-purchase",
|
||||
"profit": "reconciled-net-retained-profit",
|
||||
"minimumEligiblePerVariant": 100,
|
||||
"minimumObservationSeconds": 604800,
|
||||
"winnerPolicy": "manual-review"
|
||||
},
|
||||
"guardrails": {
|
||||
"preserveAcceptedOffers": true,
|
||||
"preserveAccruedCommissions": true,
|
||||
"requireReconciledCostsForPayout": true,
|
||||
"onBreach": "pause-new-assignments"
|
||||
},
|
||||
"economics": {
|
||||
"currency": "USD",
|
||||
"minorUnitExponent": 2,
|
||||
"unit": "agent-hour",
|
||||
"listUnitPriceMinor": 40000,
|
||||
"modeledUnitCostMinor": 10000,
|
||||
"minimumRetainedUnitProfitMinor": 5000
|
||||
}
|
||||
}
|
||||
26
packages/schemas/fixtures/openabtest/conversion.json
Normal file
26
packages/schemas/fixtures/openabtest/conversion.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"openabtest": "0.1-draft",
|
||||
"producerId": "chovy-server",
|
||||
"manifestId": "chovy-signup-discount-v1",
|
||||
"manifestRevision": 1,
|
||||
"participantId": "participant-opaque-7d12",
|
||||
"at": "2026-09-15T12:00:00Z",
|
||||
"id": "event-conversion-42",
|
||||
"kind": "conversion",
|
||||
"payload": {
|
||||
"assignmentId": "assignment-42",
|
||||
"variantId": "signup-10",
|
||||
"conversionId": "purchase-42",
|
||||
"price": {
|
||||
"offerId": "offer-42",
|
||||
"acceptedAt": "2026-09-15T11:59:00Z",
|
||||
"currency": "USD",
|
||||
"listUnitPriceMinor": 40000,
|
||||
"chargedUnitPriceMinor": 36000,
|
||||
"discountBps": 1000,
|
||||
"quantityMilliUnits": 1000,
|
||||
"rounding": "half-up",
|
||||
"totalMinor": 36000
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/schemas/fixtures/openabtest/eligibility.json
Normal file
14
packages/schemas/fixtures/openabtest/eligibility.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"openabtest": "0.1-draft",
|
||||
"producerId": "chovy-server",
|
||||
"manifestId": "chovy-signup-discount-v1",
|
||||
"manifestRevision": 1,
|
||||
"participantId": "participant-opaque-7d12",
|
||||
"at": "2026-09-15T12:00:00Z",
|
||||
"id": "event-eligibility-42",
|
||||
"kind": "eligibility",
|
||||
"payload": {
|
||||
"cohortId": "eligible-referred-customers",
|
||||
"eligible": true
|
||||
}
|
||||
}
|
||||
16
packages/schemas/fixtures/openabtest/exposure.json
Normal file
16
packages/schemas/fixtures/openabtest/exposure.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"openabtest": "0.1-draft",
|
||||
"producerId": "chovy-server",
|
||||
"manifestId": "chovy-signup-discount-v1",
|
||||
"manifestRevision": 1,
|
||||
"participantId": "participant-opaque-7d12",
|
||||
"at": "2026-09-15T12:00:00Z",
|
||||
"id": "event-exposure-42",
|
||||
"kind": "exposure",
|
||||
"payload": {
|
||||
"assignmentId": "assignment-42",
|
||||
"variantId": "signup-10",
|
||||
"offerId": "offer-42",
|
||||
"surface": "checkout"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"openabtest": "0.1-draft",
|
||||
"producerId": "chovy-server",
|
||||
"manifestId": "chovy-signup-discount-v1",
|
||||
"manifestRevision": 1,
|
||||
"participantId": "participant-opaque-7d12",
|
||||
"at": "2026-09-15T12:00:00Z",
|
||||
"id": "event-reconciliation-pending-42",
|
||||
"kind": "reconciliation",
|
||||
"payload": {
|
||||
"assignmentId": "assignment-42",
|
||||
"variantId": "signup-10",
|
||||
"conversionId": "purchase-42",
|
||||
"accountingRevision": 1,
|
||||
"currency": "USD",
|
||||
"state": "pending",
|
||||
"revenueMinor": 36000,
|
||||
"refundsMinor": 0,
|
||||
"actualCostMinor": null,
|
||||
"feesMinor": null,
|
||||
"affiliateMinor": null,
|
||||
"retainedProfitMinor": null,
|
||||
"minimumRetainedProfitMinor": 5000,
|
||||
"payoutStatus": "withheld",
|
||||
"proofRefs": []
|
||||
}
|
||||
}
|
||||
30
packages/schemas/fixtures/openabtest/reconciliation.json
Normal file
30
packages/schemas/fixtures/openabtest/reconciliation.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"openabtest": "0.1-draft",
|
||||
"producerId": "chovy-server",
|
||||
"manifestId": "chovy-signup-discount-v1",
|
||||
"manifestRevision": 1,
|
||||
"participantId": "participant-opaque-7d12",
|
||||
"at": "2026-09-15T12:00:00Z",
|
||||
"id": "event-reconciliation-42",
|
||||
"kind": "reconciliation",
|
||||
"payload": {
|
||||
"assignmentId": "assignment-42",
|
||||
"variantId": "signup-10",
|
||||
"conversionId": "purchase-42",
|
||||
"accountingRevision": 2,
|
||||
"currency": "USD",
|
||||
"state": "reconciled",
|
||||
"revenueMinor": 36000,
|
||||
"refundsMinor": 0,
|
||||
"actualCostMinor": 10000,
|
||||
"feesMinor": 1000,
|
||||
"affiliateMinor": 20000,
|
||||
"retainedProfitMinor": 5000,
|
||||
"minimumRetainedProfitMinor": 5000,
|
||||
"payoutStatus": "eligible",
|
||||
"proofRefs": [
|
||||
"cost-proof-42",
|
||||
"fees-proof-42"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@logicsrc/schemas",
|
||||
"version": "0.2.0",
|
||||
"description": "LogicSRC JSON schemas for tasks, agents, runs, events, plugins, the AgentAd ad standard, the OpenOntology knowledge contracts, the OpenContext context plane, the OpenCreds credential vault, OpenRental membership and rental offers, and the OpenWall messaging proposal.",
|
||||
"version": "0.3.0",
|
||||
"description": "LogicSRC JSON schemas for tasks, agents, runs, events, plugins, the AgentAd ad standard, the OpenOntology knowledge contracts, the OpenContext context plane, the OpenCreds credential vault, OpenRental membership and rental offers, and the OpenWall messaging proposal. Includes the OpenABTest draft experiment and event contracts.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
|
@ -26,7 +26,8 @@
|
|||
"openontology",
|
||||
"password-manager",
|
||||
"standards",
|
||||
"vault"
|
||||
"vault",
|
||||
"openabtest"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
|
@ -92,7 +93,9 @@
|
|||
"./repo": "./schemas/logicsrc-repo.schema.json",
|
||||
"./run": "./schemas/logicsrc-run.schema.json",
|
||||
"./social-post": "./schemas/logicsrc-social-post.schema.json",
|
||||
"./task": "./schemas/logicsrc-task.schema.json"
|
||||
"./task": "./schemas/logicsrc-task.schema.json",
|
||||
"./openabtest-manifest": "./schemas/logicsrc-openabtest-manifest.schema.json",
|
||||
"./openabtest-event": "./schemas/logicsrc-openabtest-event.schema.json"
|
||||
},
|
||||
"files": [
|
||||
"schemas",
|
||||
|
|
|
|||
460
packages/schemas/schemas/logicsrc-openabtest-event.schema.json
Normal file
460
packages/schemas/schemas/logicsrc-openabtest-event.schema.json
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://logicsrc.com/schemas/logicsrc-openabtest-event.schema.json",
|
||||
"title": "OpenABTest authorized event (0.1 draft)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"openabtest",
|
||||
"id",
|
||||
"producerId",
|
||||
"manifestId",
|
||||
"manifestRevision",
|
||||
"participantId",
|
||||
"at",
|
||||
"kind",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"openabtest": {
|
||||
"const": "0.1-draft"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"producerId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"manifestId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"manifestRevision": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"participantId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"eligibility",
|
||||
"assignment",
|
||||
"exposure",
|
||||
"conversion",
|
||||
"adjustment",
|
||||
"reconciliation"
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "eligibility"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"cohortId",
|
||||
"eligible"
|
||||
],
|
||||
"properties": {
|
||||
"cohortId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"eligible": {
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "assignment"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"assignmentId",
|
||||
"variantId",
|
||||
"cohortId"
|
||||
],
|
||||
"properties": {
|
||||
"assignmentId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"variantId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"cohortId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "exposure"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"assignmentId",
|
||||
"variantId",
|
||||
"offerId",
|
||||
"surface"
|
||||
],
|
||||
"properties": {
|
||||
"assignmentId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"variantId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"offerId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"surface": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "conversion"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"assignmentId",
|
||||
"variantId",
|
||||
"conversionId",
|
||||
"price"
|
||||
],
|
||||
"properties": {
|
||||
"assignmentId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"variantId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"conversionId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"price": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"offerId",
|
||||
"acceptedAt",
|
||||
"currency",
|
||||
"listUnitPriceMinor",
|
||||
"chargedUnitPriceMinor",
|
||||
"discountBps",
|
||||
"quantityMilliUnits",
|
||||
"rounding",
|
||||
"totalMinor"
|
||||
],
|
||||
"properties": {
|
||||
"offerId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"acceptedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z]{3}$"
|
||||
},
|
||||
"listUnitPriceMinor": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"chargedUnitPriceMinor": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"discountBps": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10000
|
||||
},
|
||||
"quantityMilliUnits": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"rounding": {
|
||||
"const": "half-up"
|
||||
},
|
||||
"totalMinor": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "adjustment"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"assignmentId",
|
||||
"variantId",
|
||||
"conversionId",
|
||||
"adjustmentId",
|
||||
"currency",
|
||||
"amountMinor",
|
||||
"reason",
|
||||
"proofRefs"
|
||||
],
|
||||
"properties": {
|
||||
"assignmentId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"variantId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"conversionId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"adjustmentId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z]{3}$"
|
||||
},
|
||||
"amountMinor": {
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"refund",
|
||||
"chargeback",
|
||||
"correction"
|
||||
]
|
||||
},
|
||||
"proofRefs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"maxItems": 100,
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "reconciliation"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"assignmentId",
|
||||
"variantId",
|
||||
"conversionId",
|
||||
"accountingRevision",
|
||||
"currency",
|
||||
"state",
|
||||
"revenueMinor",
|
||||
"refundsMinor",
|
||||
"actualCostMinor",
|
||||
"feesMinor",
|
||||
"affiliateMinor",
|
||||
"retainedProfitMinor",
|
||||
"minimumRetainedProfitMinor",
|
||||
"payoutStatus",
|
||||
"proofRefs"
|
||||
],
|
||||
"properties": {
|
||||
"assignmentId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"variantId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"conversionId": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"accountingRevision": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z]{3}$"
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"pending",
|
||||
"reconciled"
|
||||
]
|
||||
},
|
||||
"revenueMinor": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"refundsMinor": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"actualCostMinor": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"feesMinor": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"affiliateMinor": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"retainedProfitMinor": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"minimumRetainedProfitMinor": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"payoutStatus": {
|
||||
"enum": [
|
||||
"withheld",
|
||||
"eligible"
|
||||
]
|
||||
},
|
||||
"proofRefs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"maxItems": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://logicsrc.com/schemas/logicsrc-openabtest-manifest.schema.json",
|
||||
"title": "OpenABTest experiment manifest (0.1 draft)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"openabtest",
|
||||
"id",
|
||||
"revision",
|
||||
"name",
|
||||
"state",
|
||||
"cohort",
|
||||
"assignment",
|
||||
"variants",
|
||||
"window",
|
||||
"metrics",
|
||||
"guardrails"
|
||||
],
|
||||
"properties": {
|
||||
"openabtest": {
|
||||
"const": "0.1-draft"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1000
|
||||
},
|
||||
"state": {
|
||||
"enum": [
|
||||
"draft",
|
||||
"running",
|
||||
"paused",
|
||||
"closed"
|
||||
]
|
||||
},
|
||||
"cohort": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"eligibilityRule",
|
||||
"purchaseScope"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"eligibilityRule": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1000
|
||||
},
|
||||
"purchaseScope": {
|
||||
"enum": [
|
||||
"all-referred-purchases",
|
||||
"all-eligible-purchases",
|
||||
"first-eligible-purchase",
|
||||
"custom"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"assignment": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"unit",
|
||||
"authority",
|
||||
"keyVersion",
|
||||
"algorithm",
|
||||
"persistence"
|
||||
],
|
||||
"properties": {
|
||||
"unit": {
|
||||
"enum": [
|
||||
"user",
|
||||
"account",
|
||||
"session",
|
||||
"device",
|
||||
"custom"
|
||||
]
|
||||
},
|
||||
"authority": {
|
||||
"const": "server"
|
||||
},
|
||||
"keyVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"algorithm": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"persistence": {
|
||||
"const": "sticky"
|
||||
}
|
||||
}
|
||||
},
|
||||
"variants": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 100,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"weight",
|
||||
"parameters"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"weight": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"maxProperties": 50,
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"startsAt",
|
||||
"endsAt"
|
||||
],
|
||||
"properties": {
|
||||
"startsAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"endsAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"primary",
|
||||
"denominator",
|
||||
"conversion",
|
||||
"profit",
|
||||
"minimumEligiblePerVariant",
|
||||
"minimumObservationSeconds",
|
||||
"winnerPolicy"
|
||||
],
|
||||
"properties": {
|
||||
"primary": {
|
||||
"enum": [
|
||||
"conversion-rate",
|
||||
"retained-profit-per-eligible-visitor"
|
||||
]
|
||||
},
|
||||
"denominator": {
|
||||
"const": "unique-eligible-participants"
|
||||
},
|
||||
"conversion": {
|
||||
"const": "unique-participants-with-confirmed-purchase"
|
||||
},
|
||||
"profit": {
|
||||
"const": "reconciled-net-retained-profit"
|
||||
},
|
||||
"minimumEligiblePerVariant": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"minimumObservationSeconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"winnerPolicy": {
|
||||
"const": "manual-review"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guardrails": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"preserveAcceptedOffers",
|
||||
"preserveAccruedCommissions",
|
||||
"requireReconciledCostsForPayout",
|
||||
"onBreach"
|
||||
],
|
||||
"properties": {
|
||||
"preserveAcceptedOffers": {
|
||||
"const": true
|
||||
},
|
||||
"preserveAccruedCommissions": {
|
||||
"const": true
|
||||
},
|
||||
"requireReconciledCostsForPayout": {
|
||||
"const": true
|
||||
},
|
||||
"onBreach": {
|
||||
"const": "pause-new-assignments"
|
||||
}
|
||||
}
|
||||
},
|
||||
"economics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"currency",
|
||||
"minorUnitExponent",
|
||||
"unit",
|
||||
"listUnitPriceMinor",
|
||||
"modeledUnitCostMinor",
|
||||
"minimumRetainedUnitProfitMinor"
|
||||
],
|
||||
"properties": {
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z]{3}$"
|
||||
},
|
||||
"minorUnitExponent": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 6
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"listUnitPriceMinor": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"modeledUnitCostMinor": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"minimumRetainedUnitProfitMinor": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,3 +32,13 @@ Use `@logicsrc/validators` 0.2.0 or newer for OpenRental validation. See the
|
|||
bindings, exact decimal rental rates and CoinPay settlement metadata.
|
||||
|
||||
MIT © Profullstack, Inc.
|
||||
|
||||
## OpenABTest draft
|
||||
|
||||
Version 0.3.0 adds `OpenABTestManifest`, `OpenABTestEvent`,
|
||||
`createOpenABTestManifest` and `createOpenABTestEvent`. The constructors set
|
||||
`openabtest: "0.1-draft"`; validate their result with `@logicsrc/validators`
|
||||
using `openabtest-manifest` or `openabtest-event` before storing it. They do not
|
||||
assign participants, authenticate events or initiate payments. See the
|
||||
[OpenABTest specification](https://logicsrc.com/docs/openabtest) and its complete
|
||||
Chovy fixture for every referred purchase.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@logicsrc/sdk",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "LogicSRC SDK contract types and client interface.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
|
|||
|
|
@ -94,3 +94,6 @@ export function createAgentSwarmSession(input: {
|
|||
openspec_only: input.openspecOnly ?? false
|
||||
};
|
||||
}
|
||||
|
||||
export { createOpenABTestManifest, createOpenABTestEvent } from "./openabtest.js";
|
||||
export type { OpenABTestManifest, OpenABTestEvent, OpenABTestPrice, OpenABTestReconciliation } from "./openabtest.js";
|
||||
|
|
|
|||
81
packages/sdk/src/openabtest.ts
Normal file
81
packages/sdk/src/openabtest.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/** Draft private contracts; constructors do not assign users, authorize events or pay money. */
|
||||
export interface OpenABTestManifest {
|
||||
openabtest: "0.1-draft";
|
||||
id: string;
|
||||
revision: number;
|
||||
name: string;
|
||||
state: "draft" | "running" | "paused" | "closed";
|
||||
cohort: { id: string; eligibilityRule: string; purchaseScope: "all-referred-purchases" | "all-eligible-purchases" | "first-eligible-purchase" | "custom" };
|
||||
assignment: { unit: "user" | "account" | "session" | "device" | "custom"; authority: "server"; keyVersion: string; algorithm: string; persistence: "sticky" };
|
||||
variants: Array<{ id: string; weight: number; parameters: Record<string, string | number | boolean | null> }>;
|
||||
window: { startsAt: string; endsAt: string };
|
||||
metrics: {
|
||||
primary: "conversion-rate" | "retained-profit-per-eligible-visitor";
|
||||
denominator: "unique-eligible-participants";
|
||||
conversion: "unique-participants-with-confirmed-purchase";
|
||||
profit: "reconciled-net-retained-profit";
|
||||
minimumEligiblePerVariant: number;
|
||||
minimumObservationSeconds: number;
|
||||
winnerPolicy: "manual-review";
|
||||
};
|
||||
guardrails: { preserveAcceptedOffers: true; preserveAccruedCommissions: true; requireReconciledCostsForPayout: true; onBreach: "pause-new-assignments" };
|
||||
economics?: { currency: string; minorUnitExponent: number; unit: string; listUnitPriceMinor: number; modeledUnitCostMinor: number | null; minimumRetainedUnitProfitMinor: number };
|
||||
}
|
||||
|
||||
export interface OpenABTestPrice {
|
||||
offerId: string;
|
||||
acceptedAt: string;
|
||||
currency: string;
|
||||
listUnitPriceMinor: number;
|
||||
chargedUnitPriceMinor: number;
|
||||
discountBps: number;
|
||||
quantityMilliUnits: number;
|
||||
rounding: "half-up";
|
||||
totalMinor: number;
|
||||
}
|
||||
|
||||
type Assigned = { assignmentId: string; variantId: string };
|
||||
type Converted = Assigned & { conversionId: string };
|
||||
export interface OpenABTestReconciliation extends Converted {
|
||||
accountingRevision: number;
|
||||
currency: string;
|
||||
state: "pending" | "reconciled";
|
||||
revenueMinor: number;
|
||||
refundsMinor: number;
|
||||
actualCostMinor: number | null;
|
||||
feesMinor: number | null;
|
||||
affiliateMinor: number | null;
|
||||
retainedProfitMinor: number | null;
|
||||
minimumRetainedProfitMinor: number;
|
||||
payoutStatus: "withheld" | "eligible";
|
||||
proofRefs: string[];
|
||||
}
|
||||
|
||||
type EventDetail =
|
||||
| { kind: "eligibility"; payload: { cohortId: string; eligible: true } }
|
||||
| { kind: "assignment"; payload: Assigned & { cohortId: string } }
|
||||
| { kind: "exposure"; payload: Assigned & { offerId: string; surface: string } }
|
||||
| { kind: "conversion"; payload: Converted & { price: OpenABTestPrice } }
|
||||
| { kind: "adjustment"; payload: Converted & { adjustmentId: string; currency: string; amountMinor: number; reason: "refund" | "chargeback" | "correction"; proofRefs: string[] } }
|
||||
| { kind: "reconciliation"; payload: OpenABTestReconciliation };
|
||||
|
||||
export type OpenABTestEvent = {
|
||||
openabtest: "0.1-draft";
|
||||
id: string;
|
||||
producerId: string;
|
||||
manifestId: string;
|
||||
manifestRevision: number;
|
||||
participantId: string;
|
||||
at: string;
|
||||
} & EventDetail;
|
||||
|
||||
/** Validate with @logicsrc/validators before storing or using the document. */
|
||||
export function createOpenABTestManifest(input: Omit<OpenABTestManifest, "openabtest">): OpenABTestManifest {
|
||||
return { ...input, openabtest: "0.1-draft" };
|
||||
}
|
||||
|
||||
type EventInput = Omit<OpenABTestEvent, "openabtest" | "kind" | "payload"> & EventDetail;
|
||||
/** The caller supplies authenticated context and durable IDs; this helper creates neither. */
|
||||
export function createOpenABTestEvent(input: EventInput): OpenABTestEvent {
|
||||
return { ...input, openabtest: "0.1-draft" };
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@logicsrc/validators",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "LogicSRC schema validation helpers.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -11,10 +11,10 @@
|
|||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run src",
|
||||
"validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json && node dist/cli.js openrental ../schemas/fixtures/openrental/mixed.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/broadcast.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/direct.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/announcement.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-accepted.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-retrying.json"
|
||||
"validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json && node dist/cli.js openrental ../schemas/fixtures/openrental/mixed.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/broadcast.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/direct.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/announcement.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-accepted.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-retrying.json && node dist/cli.js openabtest-manifest ../schemas/fixtures/openabtest/chovy-manifest.json && node dist/cli.js openabtest-event ../schemas/fixtures/openabtest/reconciliation.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logicsrc/schemas": "^0.2.0",
|
||||
"@logicsrc/schemas": "^0.3.0",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"yaml": "^2.8.1"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import * as addFormatsModule from "ajv-formats";
|
|||
import type { ErrorObject } from "ajv";
|
||||
import { parse } from "yaml";
|
||||
import { isSchemaKind, schemas, type SchemaKind } from "./schemas.js";
|
||||
import { validateOpenABTest } from "./openabtest.js";
|
||||
import { validateOpenRentalReferences } from "./openrental.js";
|
||||
|
||||
type CompiledSchema = { (data: unknown): boolean; errors?: ErrorObject[] | null };
|
||||
|
|
@ -66,6 +67,10 @@ export function validate(kind: SchemaKind, data: unknown): ValidationResult {
|
|||
const errors = validateOpenRentalReferences(data);
|
||||
if (errors.length) return { ok: false, kind, errors };
|
||||
}
|
||||
if (kind === "openabtest-manifest" || kind === "openabtest-event") {
|
||||
const errors = validateOpenABTest(kind, data);
|
||||
if (errors.length) return { ok: false, kind, errors };
|
||||
}
|
||||
return { ok: true, kind, data };
|
||||
}
|
||||
|
||||
|
|
|
|||
103
packages/validators/src/openabtest.test.ts
Normal file
103
packages/validators/src/openabtest.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validate } from "./index.js";
|
||||
|
||||
function fixture(name: string) {
|
||||
return JSON.parse(readFileSync(new URL(`../../schemas/fixtures/openabtest/${name}.json`, import.meta.url), "utf8"));
|
||||
}
|
||||
|
||||
describe("OpenABTest draft contracts", () => {
|
||||
it("accepts the every-referred-purchase experiment", () => {
|
||||
expect(validate("openabtest-manifest", fixture("chovy-manifest")).ok).toBe(true);
|
||||
});
|
||||
it.each(["eligibility", "assignment", "exposure", "conversion", "adjustment", "reconciliation-pending", "reconciliation"])("accepts %s independently", (name) => {
|
||||
expect(validate("openabtest-event", fixture(name)).ok).toBe(true);
|
||||
});
|
||||
it("rejects duplicate variant identities even when weights differ", () => {
|
||||
const manifest = fixture("chovy-manifest");
|
||||
manifest.variants[1].id = manifest.variants[0].id;
|
||||
manifest.variants[1].weight = 2;
|
||||
expect(validate("openabtest-manifest", manifest).ok).toBe(false);
|
||||
});
|
||||
it.each([0, -1, 0.5, Number.MAX_SAFE_INTEGER + 1])("rejects invalid weight %s", (weight) => {
|
||||
const manifest = fixture("chovy-manifest");
|
||||
manifest.variants[0].weight = weight;
|
||||
expect(validate("openabtest-manifest", manifest).ok).toBe(false);
|
||||
});
|
||||
it("rejects unsafe sums and backwards windows", () => {
|
||||
const manifest = fixture("chovy-manifest");
|
||||
manifest.variants[0].weight = Number.MAX_SAFE_INTEGER;
|
||||
expect(validate("openabtest-manifest", manifest).ok).toBe(false);
|
||||
manifest.variants[0].weight = 1;
|
||||
manifest.window.endsAt = manifest.window.startsAt;
|
||||
expect(validate("openabtest-manifest", manifest).ok).toBe(false);
|
||||
});
|
||||
it("rejects client pricing authority and malformed discounts", () => {
|
||||
const manifest = fixture("chovy-manifest");
|
||||
manifest.assignment.authority = "client";
|
||||
expect(validate("openabtest-manifest", manifest).ok).toBe(false);
|
||||
manifest.assignment.authority = "server";
|
||||
manifest.variants[0].parameters.discountBps = "500";
|
||||
expect(validate("openabtest-manifest", manifest).ok).toBe(false);
|
||||
});
|
||||
it.each(["assignment", "exposure", "conversion"])("requires assignment attribution on %s", (name) => {
|
||||
const event = fixture(name);
|
||||
delete event.payload.assignmentId;
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
});
|
||||
it("rejects unrecognized event payloads and identifiable participant values", () => {
|
||||
const event = fixture("exposure");
|
||||
event.payload.email = "person@example.com";
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
delete event.payload.email;
|
||||
event.participantId = "person@example.com";
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
});
|
||||
it("checks the accepted discount and half-up usage arithmetic", () => {
|
||||
const event = fixture("conversion");
|
||||
event.payload.price.chargedUnitPriceMinor = 38000;
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
Object.assign(event.payload.price, { listUnitPriceMinor: 101, chargedUnitPriceMinor: 91, quantityMilliUnits: 500, totalMinor: 46 });
|
||||
expect(validate("openabtest-event", event).ok).toBe(true);
|
||||
event.payload.price.totalMinor = 45;
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
});
|
||||
it("rejects a conversion dated before the accepted offer", () => {
|
||||
const event = fixture("conversion");
|
||||
event.at = "2026-09-14T00:00:00Z";
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
});
|
||||
it("requires a revenue reduction and evidence for refunds", () => {
|
||||
const event = fixture("adjustment");
|
||||
event.payload.amountMinor = 3600;
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
event.payload.amountMinor = -3600;
|
||||
event.payload.proofRefs = [];
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
});
|
||||
it("withholds payouts when actual costs or fees are missing", () => {
|
||||
const event = fixture("reconciliation");
|
||||
event.payload.actualCostMinor = null;
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
event.payload.state = "pending";
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
Object.assign(event.payload, { affiliateMinor: null, retainedProfitMinor: null, payoutStatus: "withheld" });
|
||||
expect(validate("openabtest-event", event).ok).toBe(true);
|
||||
});
|
||||
it("rejects inconsistent accounting and unsupported profit claims", () => {
|
||||
const event = fixture("reconciliation");
|
||||
event.payload.affiliateMinor = 21000;
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
event.payload.retainedProfitMinor = 4000;
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
Object.assign(event.payload, { affiliateMinor: 0, actualCostMinor: 36000, retainedProfitMinor: -1000, payoutStatus: "withheld" });
|
||||
expect(validate("openabtest-event", event).ok).toBe(true);
|
||||
});
|
||||
it("requires reconciliation evidence without bearer URLs", () => {
|
||||
const event = fixture("reconciliation");
|
||||
event.payload.proofRefs = [];
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
event.payload.proofRefs = ["https://example.com/receipt?token=secret"];
|
||||
expect(validate("openabtest-event", event).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
68
packages/validators/src/openabtest.ts
Normal file
68
packages/validators/src/openabtest.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { ErrorObject } from "ajv";
|
||||
|
||||
type AccountingEvent =
|
||||
| { kind: "conversion"; at: string; payload: { price: { listUnitPriceMinor: number; discountBps: number; quantityMilliUnits: number; chargedUnitPriceMinor: number; totalMinor: number; acceptedAt: string } } }
|
||||
| { kind: "adjustment"; payload: { reason: string; amountMinor: number } }
|
||||
| { kind: "reconciliation"; payload: { revenueMinor: number; refundsMinor: number; actualCostMinor: number | null; feesMinor: number | null; affiliateMinor: number | null; retainedProfitMinor: number | null; minimumRetainedProfitMinor: number; state: string; payoutStatus: string; proofRefs: string[] } };
|
||||
|
||||
// These checks run only after structural validation. Cross-event authorization,
|
||||
// assignment lookup and idempotency still require the issuer's private ledger.
|
||||
export function validateOpenABTest(kind: string, data: unknown): ErrorObject[] {
|
||||
const errors: ErrorObject[] = [];
|
||||
function report(path: string, message: string) {
|
||||
errors.push({ keyword: "openabtest", instancePath: path, schemaPath: "#/openabtest-semantics", params: {}, message });
|
||||
}
|
||||
if (kind === "openabtest-manifest") {
|
||||
const manifest = data as {
|
||||
variants: Array<{ id: string; weight: number; parameters: Record<string, unknown> }>;
|
||||
window: { startsAt: string; endsAt: string };
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
let total = 0n;
|
||||
manifest.variants.forEach((variant, index) => {
|
||||
if (seen.has(variant.id)) report(`/variants/${index}/id`, "must be unique within the manifest");
|
||||
seen.add(variant.id);
|
||||
total += BigInt(variant.weight);
|
||||
const discount = variant.parameters.discountBps;
|
||||
if (discount !== undefined && (typeof discount !== "number" || !Number.isInteger(discount) || discount < 0 || discount > 10000)) {
|
||||
report(`/variants/${index}/parameters/discountBps`, "must be an integer from 0 to 10000");
|
||||
}
|
||||
});
|
||||
if (total > BigInt(Number.MAX_SAFE_INTEGER)) report("/variants", "total weight must be a safe integer");
|
||||
if (Date.parse(manifest.window.endsAt) <= Date.parse(manifest.window.startsAt)) {
|
||||
report("/window/endsAt", "must be later than startsAt");
|
||||
}
|
||||
} else {
|
||||
const event = data as AccountingEvent;
|
||||
if (event.kind === "conversion") {
|
||||
const price = event.payload.price;
|
||||
const unit = (BigInt(price.listUnitPriceMinor) * BigInt(10000 - price.discountBps) + 5000n) / 10000n;
|
||||
const total = (unit * BigInt(price.quantityMilliUnits) + 500n) / 1000n;
|
||||
if (BigInt(price.chargedUnitPriceMinor) !== unit) report("/payload/price/chargedUnitPriceMinor", "must equal the discounted list unit price rounded half up");
|
||||
if (BigInt(price.totalMinor) !== total) report("/payload/price/totalMinor", "must equal the charged unit price times quantity rounded half up");
|
||||
if (Date.parse(price.acceptedAt) > Date.parse(event.at)) report("/payload/price/acceptedAt", "cannot be after the conversion event");
|
||||
}
|
||||
if (event.kind === "adjustment" && (event.payload.reason === "refund" || event.payload.reason === "chargeback") && event.payload.amountMinor >= 0) {
|
||||
report("/payload/amountMinor", "refunds and chargebacks must reduce recognized revenue");
|
||||
}
|
||||
if (event.kind === "reconciliation") {
|
||||
const p = event.payload;
|
||||
if (p.refundsMinor > p.revenueMinor) report("/payload/refundsMinor", "cannot exceed revenue; other expenses belong in actual costs or fees");
|
||||
if (p.state === "pending") {
|
||||
if (p.payoutStatus !== "withheld" || p.affiliateMinor !== null || p.retainedProfitMinor !== null) {
|
||||
report("/payload", "pending reconciliation must withhold payout and leave affiliate and retained profit unknown");
|
||||
}
|
||||
} else if (p.actualCostMinor === null || p.feesMinor === null || p.affiliateMinor === null || p.retainedProfitMinor === null) {
|
||||
report("/payload", "reconciled accounting requires actual costs, fees, affiliate and retained profit");
|
||||
} else {
|
||||
const retained = BigInt(p.revenueMinor) - BigInt(p.refundsMinor) - BigInt(p.actualCostMinor) - BigInt(p.feesMinor) - BigInt(p.affiliateMinor);
|
||||
if (retained !== BigInt(p.retainedProfitMinor)) report("/payload/retainedProfitMinor", "must reconcile exactly to net revenue less actual costs, fees and affiliate allocation");
|
||||
if (p.proofRefs.length === 0) report("/payload/proofRefs", "reconciled accounting requires private evidence references");
|
||||
if (p.retainedProfitMinor < p.minimumRetainedProfitMinor && (p.affiliateMinor !== 0 || p.payoutStatus !== "withheld")) {
|
||||
report("/payload", "a profit-floor shortfall must withhold new payout and allocate zero new affiliate amount");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import openabtestManifestSchema from "@logicsrc/schemas/openabtest-manifest" with { type: "json" };
|
||||
import openabtestEventSchema from "@logicsrc/schemas/openabtest-event" with { type: "json" };
|
||||
/**
|
||||
* Every LogicSRC JSON Schema, keyed by kind.
|
||||
*
|
||||
|
|
@ -73,6 +75,8 @@ import openwallMessageSchema from "@logicsrc/schemas/openwall-message" with { ty
|
|||
import openwallReceiptSchema from "@logicsrc/schemas/openwall-receipt" with { type: "json" };
|
||||
|
||||
export const schemas = {
|
||||
"openabtest-manifest": openabtestManifestSchema,
|
||||
"openabtest-event": openabtestEventSchema,
|
||||
"openwall-message": openwallMessageSchema,
|
||||
"openwall-receipt": openwallReceiptSchema,
|
||||
agent: agentSchema,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue