mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 12:54:03 +00:00
Add OpenABTest draft experiment and accounting contracts
This commit is contained in:
parent
123ba2e39a
commit
7fb67375a5
25 changed files with 1410 additions and 12 deletions
|
|
@ -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