Wait-via-NATS returns completed or pending+jobId (GATE 9). Mock SHA256
idempotent register + GET /hashes/{sha256}. Zappier commercial edge has
POST /v1/timestamp and hash-lookup. GATE 12 smoke (signup + wait) passes.
NS1 NATS is 127.0.0.1:4222 on 70.88.205.138; SSH tunnel :14222. Local
nats-server -js used for isolated tests. Activate app adds Echo Text.
Learned: JetStream on NS1 is loopback-only; do not bind 4222 public.
172 lines
4.4 KiB
TypeScript
172 lines
4.4 KiB
TypeScript
export type PriceRule =
|
|
| { kind: 'free' }
|
|
| { kind: 'fixed'; fixedCents: number }
|
|
| { kind: 'variable'; baseCents: number; perKbCents: number; perMbCents: number };
|
|
|
|
export interface TierConfig {
|
|
id: string;
|
|
name: string;
|
|
multiplier: number;
|
|
monthlyCreditCents: number;
|
|
defaultRule?: PriceRule;
|
|
}
|
|
|
|
export interface RateCard {
|
|
endpoints: Record<string, PriceRule>;
|
|
}
|
|
|
|
export interface TierCatalog {
|
|
find(id: string): TierConfig | undefined;
|
|
list(): TierConfig[];
|
|
}
|
|
|
|
export class ConfigTierCatalog implements TierCatalog {
|
|
constructor(private tiers: TierConfig[]) {}
|
|
|
|
find(id: string): TierConfig | undefined {
|
|
return this.tiers.find((t) => t.id === id);
|
|
}
|
|
|
|
list(): TierConfig[] {
|
|
return [...this.tiers];
|
|
}
|
|
}
|
|
|
|
export interface CallUsage {
|
|
metadataBytes: number;
|
|
attachmentBytes: number;
|
|
}
|
|
|
|
export interface Quote {
|
|
endpointId: string;
|
|
listCents: number;
|
|
totalCents: number;
|
|
breakdown: { baseCents: number; metadataCents: number; attachmentCents: number };
|
|
}
|
|
|
|
export interface PricingContext {
|
|
rateCard: RateCard;
|
|
tiers: TierCatalog;
|
|
}
|
|
|
|
export interface PricingStore {
|
|
getRateCard(): RateCard;
|
|
getTiers(): TierConfig[];
|
|
upsertEndpoint(endpointId: string, rule: PriceRule): void;
|
|
deleteEndpoint(endpointId: string): void;
|
|
upsertTier(tier: TierConfig): void;
|
|
deleteTier(tierId: string): void;
|
|
}
|
|
|
|
export const DEFAULT_RATE_CARD: RateCard = {
|
|
endpoints: {
|
|
status: { kind: 'free' },
|
|
'storage-list': { kind: 'free' },
|
|
transform: { kind: 'fixed', fixedCents: 4 },
|
|
add: { kind: 'free' },
|
|
timestamp: { kind: 'fixed', fixedCents: 4 },
|
|
'hash-lookup': { kind: 'free' },
|
|
storage: { kind: 'variable', baseCents: 10, perKbCents: 1, perMbCents: 50 },
|
|
},
|
|
};
|
|
|
|
export const DEFAULT_TIERS: TierConfig[] = [
|
|
{ id: 'free', name: 'Free', multiplier: 1, monthlyCreditCents: 100 },
|
|
{
|
|
id: 'pro',
|
|
name: 'Pro',
|
|
multiplier: 0.5,
|
|
monthlyCreditCents: 1000,
|
|
defaultRule: { kind: 'fixed', fixedCents: 8 },
|
|
},
|
|
{
|
|
id: 'business',
|
|
name: 'Business',
|
|
multiplier: 0.25,
|
|
monthlyCreditCents: 10000,
|
|
defaultRule: { kind: 'fixed', fixedCents: 8 },
|
|
},
|
|
];
|
|
|
|
export const DEFAULT_PRICING: PricingContext = {
|
|
rateCard: DEFAULT_RATE_CARD,
|
|
tiers: new ConfigTierCatalog(DEFAULT_TIERS),
|
|
};
|
|
|
|
export class InMemoryPricingStore implements PricingStore {
|
|
private endpoints: Record<string, PriceRule>;
|
|
private tiers: TierConfig[];
|
|
|
|
constructor(rateCard: RateCard = DEFAULT_RATE_CARD, tiers: TierConfig[] = DEFAULT_TIERS) {
|
|
this.endpoints = { ...rateCard.endpoints };
|
|
this.tiers = [...tiers];
|
|
}
|
|
|
|
getRateCard(): RateCard {
|
|
return { endpoints: { ...this.endpoints } };
|
|
}
|
|
|
|
getTiers(): TierConfig[] {
|
|
return [...this.tiers];
|
|
}
|
|
|
|
upsertEndpoint(endpointId: string, rule: PriceRule): void {
|
|
this.endpoints[endpointId] = rule;
|
|
}
|
|
|
|
deleteEndpoint(endpointId: string): void {
|
|
delete this.endpoints[endpointId];
|
|
}
|
|
|
|
upsertTier(tier: TierConfig): void {
|
|
const i = this.tiers.findIndex((t) => t.id === tier.id);
|
|
if (i >= 0) this.tiers[i] = tier;
|
|
else this.tiers.push(tier);
|
|
}
|
|
|
|
deleteTier(tierId: string): void {
|
|
this.tiers = this.tiers.filter((t) => t.id !== tierId);
|
|
}
|
|
}
|
|
|
|
export function quoteCall(
|
|
pricing: PricingContext,
|
|
tierId: string,
|
|
endpointId: string,
|
|
usage: CallUsage,
|
|
multiplierOverride?: number,
|
|
): Quote {
|
|
const tier = pricing.tiers.find(tierId);
|
|
if (!tier) throw new Error(`Unknown tier: ${tierId}`);
|
|
const rule = pricing.rateCard.endpoints[endpointId] ?? tier.defaultRule;
|
|
if (!rule) throw new Error(`No price rule for ${tierId}/${endpointId}`);
|
|
|
|
if (rule.kind === 'free') {
|
|
return {
|
|
endpointId,
|
|
listCents: 0,
|
|
totalCents: 0,
|
|
breakdown: { baseCents: 0, metadataCents: 0, attachmentCents: 0 },
|
|
};
|
|
}
|
|
|
|
let breakdown: Quote['breakdown'];
|
|
if (rule.kind === 'fixed') {
|
|
breakdown = { baseCents: rule.fixedCents, metadataCents: 0, attachmentCents: 0 };
|
|
} else {
|
|
breakdown = {
|
|
baseCents: rule.baseCents,
|
|
metadataCents: Math.ceil(usage.metadataBytes / 1024) * rule.perKbCents,
|
|
attachmentCents:
|
|
Math.ceil(usage.attachmentBytes / (1024 * 1024)) * rule.perMbCents,
|
|
};
|
|
}
|
|
const listCents = breakdown.baseCents + breakdown.metadataCents + breakdown.attachmentCents;
|
|
const multiplier = multiplierOverride ?? tier.multiplier;
|
|
return {
|
|
endpointId,
|
|
listCents,
|
|
totalCents: Math.round(listCents * multiplier),
|
|
breakdown,
|
|
};
|
|
}
|