@classytic/payee
v0.3.0
Published
Subject-polymorphic payout-recipient engine — verified payout methods (mobile money / bank transfer) + KYC status + an assertPayable eligibility gate. KYC execution is bring-your-own via a ComplianceBridge. Consumed by @classytic/revenue (settlement) an
Readme
@classytic/payee
Subject-polymorphic payout-recipient engine — verified payout methods, KYC status, and one assertPayable eligibility gate. The piece that sits below the money engines: @classytic/revenue moves money and @classytic/payrun runs payouts; payee answers where to send it and whether the party is allowed to be paid.
Aligned with the universal fintech shape — a PayeeAccount ≈ Stripe connected account / PayPal recipient (holds accountHolderType + KYC verification state + outstanding requirements), and a PayoutMethod ≈ Stripe external account / ISO 20022 creditor account (cross-rail: US ACH, EU IBAN/BIC, UK sort code, mobile money). Any payment processor on MongoDB can adopt it.
KYC execution is bring-your-own via a ComplianceBridge (Sumsub / Persona / Onfido / Stripe Identity / a manual review queue). The package stores status, never the verification logic. It moves no money, so it has no LedgerBridge.
Install
npm i @classytic/payeePeers: @classytic/mongokit >=3.16, @classytic/primitives >=0.7.1, @classytic/repo-core >=0.6, mongoose >=9.4, zod >=4.
Quick start
import { createPayee } from '@classytic/payee';
const payee = await createPayee({
connection: mongoose.connection,
tenant: { fieldType: 'objectId' }, // or `false` for platform-level referrers
bridges: { compliance: myKycProvider }, // optional — defaults to manual review
fieldCipher: myKmsCipher, // PII encryption at rest — see below
});
const subject = { subjectRef: 'orgprofile_123', subjectModel: 'OrgProfile' };
await payee.repositories.payeeAccount.ensureAccount(subject, ctx);
const method = await payee.repositories.payoutMethod.addMethod(
{ ...subject, kind: 'mobile_money', methodCode: 'bkash', walletNumber: '+8801700000000', currency: 'BDT', countryCode: 'BD' },
ctx,
);
await payee.repositories.payoutMethod.verify(String(method._id), { verifiedBy: 'mgr' }, ctx);
await payee.repositories.payoutMethod.setDefault(String(method._id), ctx);
await payee.repositories.payeeAccount.submitForReview(subject.subjectRef, subject.subjectModel, { level: 'full' }, ctx);
await payee.repositories.payeeAccount.recordDecision(subject.subjectRef, subject.subjectModel, { approved: true }, ctx);The gate
// Both revenue.settlement and payrun.markPaid call this before disbursing.
const { account, method } = await payee.assertPayable(subjectRef, subjectModel, ctx);
// throws PayeeNotPayableError unless KYC === 'verified' AND a verified default method exists.Honesty note (TOCTOU): assertPayable is a read — a method can be disabled between the gate and the disbursement. Call it inside the disbursing transaction (thread ctx.session, as the integration example below does) when that window matters.
PII at rest (fieldCipher)
Payout credentials (accountNumber / iban / walletNumber) are the fields a DB dump turns into fraud. Wire a cipher and they're encrypted before persistence (with last4 derived from the plaintext first); list/get APIs and arc adapters only ever see ciphertext + last4:
const payee = await createPayee({
connection,
fieldCipher: {
encrypt: (v) => kms.encrypt(v), // KMS envelope / AES-GCM — your choice
decrypt: (v) => kms.decrypt(v),
},
});
// The ONLY plaintext path — call at disbursement time, never from list views:
const { method, destination } = await payee.revealDestination(String(m._id), ctx);
rail.send({ account: destination.accountNumber, ... });No cipher = plaintext passthrough (dev only — revealDestination works identically either way, so host code doesn't change when you turn encryption on). Dedupe uses the host-computed fingerprint, never the ciphertext.
Re-KYC
verified → pending is a legal transition (periodic AML re-verification / expired documents): startKyc / submitForReview on a verified account pauses assertPayable until the new decision lands.
Integration (host orchestrates — payee imports neither engine)
await payrun.withTransaction(ctx, async (tx) => {
const { method } = await payee.assertPayable(subjectRef, subjectModel, tx);
const run = await payrun.repositories.payrollRun.markPaid(runId, input, tx);
await revenue.repositories.settlement.schedule(
{ recipientId: String(method._id), payoutMethod: method.kind, bankTransferDetails: method.details },
{ session: tx.session },
);
});Arc
Wire the repositories with createAdapter(engine.models.X, engine.repositories.x); expose status transitions as Arc actions and gate raw CRUD (rules 27–30). Register the event catalog:
import { payeeEventDefinitions } from '@classytic/payee/events';
for (const def of payeeEventDefinitions) registry.register(def);Bridges (all optional, rule 23)
ComplianceBridge— KYC execution (startVerification/getStatus/onDecision).NotificationBridge— "you're verified" / "payout method added".SubjectBridge— resolve the subject (creator/referrer) to a display snapshot. Same shape as payrun's, so a host wires it once.
Cleanup (org delete / off-boarding)
mongokit's deleteMany (tenant-scoped, hook-fired, soft-delete-aware) backs two engine verbs so the host never hand-rolls cleanup:
engine.purgeSubject(subjectRef, subjectModel, ctx, { mode? })— a creator/referrer off-boards. Soft delete (default) frees the unique-per-subject slot so they can re-onboard later;{ mode: 'hard' }is GDPR erasure.engine.purgeOrganization(ctx, { mode? })— the organization is deleted. Tenant-scoped, so only that org's accounts + methods go.
Wire into the host's org-delete flow:
// Better Auth org hook (or arc defineResource({ onTenantDelete }))
afterDeleteOrganization: async (org) => {
await payee.purgeOrganization(
{ organizationId: org.id, actorId: 'system', actorKind: 'system' },
{ mode: 'hard' },
);
},Conventions
Built on @classytic/primitives (PaymentMethodKind, PersonName, ApprovalChain, OperationContext, DomainEvent/EventTransport/OutboxStore) and @classytic/repo-core (tenant, errors, pagination) per PACKAGE_RULES.md. ESM-only, no sourcemaps/declaration maps, subpath exports (no barrels), verbatimModuleSyntax. Tests follow testing-infrastructure.md (unit + integration, tenant probe, event no-drift).
