From 443e1beb6fd378e927c8a00c92b4d22dbec11610 Mon Sep 17 00:00:00 2001 From: Tisan Date: Thu, 22 Oct 2026 08:18:35 +0000 Subject: [PATCH 1/4] fix: #977 Add invoice financing options for quick cash access Closes #977 --- src/lib/financing.ts | 298 +++++++++++++++++++++++++++++++++++++++++ src/types/financing.ts | 121 +++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 src/lib/financing.ts create mode 100644 src/types/financing.ts diff --git a/src/lib/financing.ts b/src/lib/financing.ts new file mode 100644 index 0000000..456743f --- /dev/null +++ b/src/lib/financing.ts @@ -0,0 +1,298 @@ +export type FinancingStatus = + | 'draft' + | 'submitted' + | 'under_review' + | 'approved' + | 'rejected' + | 'disbursed' + | 'repaid'; + +export interface FinancingProduct { + id: string; + name: string; + /** Fraction of invoice face value advanced up front (0-1). */ + advanceRate: number; + /** Flat fee rate applied to the advanced amount (0-1). */ + feeRate: number; + /** Minimum invoice amount eligible for financing. */ + minInvoiceAmount: number; + /** Maximum invoice amount eligible for financing. */ + maxInvoiceAmount: number; + /** Minimum creator account age in days to be eligible. */ + minAccountAgeDays: number; + /** Minimum number of previously paid invoices to be eligible. */ + minPaidInvoices: number; + /** Term length in days for the advance. */ + termDays: number; +} + +export interface Invoice { + id: string; + creatorId: string; + amount: number; + currency: string; + dueDate: string; + status: 'open' | 'paid' | 'overdue'; +} + +export interface CreatorProfile { + id: string; + accountAgeDays: number; + paidInvoiceCount: number; +} + +export interface FinancingApplication { + id: string; + invoiceId: string; + creatorId: string; + productId: string; + requestedAmount: number; + status: FinancingStatus; + createdAt: string; + updatedAt: string; + reviewedAt?: string; + disbursedAt?: string; + rejectionReason?: string; +} + +export interface FeeBreakdown { + advanceAmount: number; + feeAmount: number; + totalRepayment: number; + netDisbursement: number; +} + +export interface Disbursement { + applicationId: string; + amount: number; + currency: string; + disbursedAt: string; +} + +export interface FinancingAnalytics { + totalApplications: number; + approvedCount: number; + rejectedCount: number; + disbursedCount: number; + totalAdvanced: number; + totalFees: number; + approvalRate: number; +} + +export const DEFAULT_FINANCING_PRODUCT: FinancingProduct = { + id: 'standard-advance', + name: 'Standard Invoice Advance', + advanceRate: 0.8, + feeRate: 0.03, + minInvoiceAmount: 100, + maxInvoiceAmount: 100000, + minAccountAgeDays: 30, + minPaidInvoices: 3, + termDays: 30, +}; + +export interface EligibilityResult { + eligible: boolean; + reasons: string[]; +} + +export function checkEligibility( + invoice: Invoice, + creator: CreatorProfile, + product: FinancingProduct = DEFAULT_FINANCING_PRODUCT, +): EligibilityResult { + const reasons: string[] = []; + + if (invoice.status !== 'open') { + reasons.push('Invoice must be open to finance'); + } + if (invoice.amount < product.minInvoiceAmount) { + reasons.push(`Invoice amount below minimum of ${product.minInvoiceAmount}`); + } + if (invoice.amount > product.maxInvoiceAmount) { + reasons.push(`Invoice amount above maximum of ${product.maxInvoiceAmount}`); + } + if (creator.accountAgeDays < product.minAccountAgeDays) { + reasons.push(`Account must be at least ${product.minAccountAgeDays} days old`); + } + if (creator.paidInvoiceCount < product.minPaidInvoices) { + reasons.push(`Requires at least ${product.minPaidInvoices} previously paid invoices`); + } + + return { eligible: reasons.length === 0, reasons }; +} + +export function calculateFees( + invoiceAmount: number, + product: FinancingProduct = DEFAULT_FINANCING_PRODUCT, +): FeeBreakdown { + const advanceAmount = round2(invoiceAmount * product.advanceRate); + const feeAmount = round2(advanceAmount * product.feeRate); + const totalRepayment = round2(advanceAmount + feeAmount); + const netDisbursement = round2(advanceAmount - feeAmount); + + return { advanceAmount, feeAmount, totalRepayment, netDisbursement }; +} + +export interface CreateApplicationInput { + id: string; + invoice: Invoice; + creator: CreatorProfile; + product?: FinancingProduct; + now?: string; +} + +export interface CreateApplicationResult { + application?: FinancingApplication; + eligibility: EligibilityResult; + fees?: FeeBreakdown; +} + +export function createApplication(input: CreateApplicationInput): CreateApplicationResult { + const product = input.product ?? DEFAULT_FINANCING_PRODUCT; + const eligibility = checkEligibility(input.invoice, input.creator, product); + + if (!eligibility.eligible) { + return { eligibility }; + } + + const fees = calculateFees(input.invoice.amount, product); + const now = input.now ?? new Date().toISOString(); + + const application: FinancingApplication = { + id: input.id, + invoiceId: input.invoice.id, + creatorId: input.creator.id, + productId: product.id, + requestedAmount: fees.advanceAmount, + status: 'submitted', + createdAt: now, + updatedAt: now, + }; + + return { application, eligibility, fees }; +} + +const ALLOWED_TRANSITIONS: Record = { + draft: ['submitted'], + submitted: ['under_review', 'rejected'], + under_review: ['approved', 'rejected'], + approved: ['disbursed'], + rejected: [], + disbursed: ['repaid'], + repaid: [], +}; + +export function canTransition(from: FinancingStatus, to: FinancingStatus): boolean { + return ALLOWED_TRANSITIONS[from]?.includes(to) ?? false; +} + +export interface TransitionInput { + application: FinancingApplication; + to: FinancingStatus; + now?: string; + rejectionReason?: string; +} + +export function transitionApplication(input: TransitionInput): FinancingApplication { + const { application, to } = input; + + if (!canTransition(application.status, to)) { + throw new Error(`Invalid financing transition: ${application.status} -> ${to}`); + } + + const now = input.now ?? new Date().toISOString(); + const updated: FinancingApplication = { + ...application, + status: to, + updatedAt: now, + }; + + if (to === 'approved' || to === 'rejected') { + updated.reviewedAt = now; + } + if (to === 'rejected' && input.rejectionReason) { + updated.rejectionReason = input.rejectionReason; + } + + return updated; +} + +export interface DisburseInput { + application: FinancingApplication; + invoice: Invoice; + product?: FinancingProduct; + now?: string; +} + +export function disburseAdvance(input: DisburseInput): { + application: FinancingApplication; + disbursement: Disbursement; + fees: FeeBreakdown; +} { + const { application, invoice } = input; + + if (application.status !== 'approved') { + throw new Error('Only approved applications can be disbursed'); + } + + const product = input.product ?? DEFAULT_FINANCING_PRODUCT; + const fees = calculateFees(invoice.amount, product); + const now = input.now ?? new Date().toISOString(); + + const updated: FinancingApplication = { + ...application, + status: 'disbursed', + disbursedAt: now, + updatedAt: now, + }; + + const disbursement: Disbursement = { + applicationId: application.id, + amount: fees.netDisbursement, + currency: invoice.currency, + disbursedAt: now, + }; + + return { application: updated, disbursement, fees }; +} + +export function computeAnalytics( + applications: FinancingApplication[], + product: FinancingProduct = DEFAULT_FINANCING_PRODUCT, +): FinancingAnalytics { + const totalApplications = applications.length; + const approvedCount = applications.filter( + (a) => a.status === 'approved' || a.status === 'disbursed' || a.status === 'repaid', + ).length; + const rejectedCount = applications.filter((a) => a.status === 'rejected').length; + const disbursed = applications.filter( + (a) => a.status === 'disbursed' || a.status === 'repaid', + ); + const disbursedCount = disbursed.length; + + let totalAdvanced = 0; + let totalFees = 0; + for (const app of disbursed) { + const fees = calculateFees(app.requestedAmount / product.advanceRate, product); + totalAdvanced += fees.advanceAmount; + totalFees += fees.feeAmount; + } + + const decided = approvedCount + rejectedCount; + const approvalRate = decided === 0 ? 0 : approvedCount / decided; + + return { + totalApplications, + approvedCount, + rejectedCount, + disbursedCount, + totalAdvanced: round2(totalAdvanced), + totalFees: round2(totalFees), + approvalRate, + }; +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/src/types/financing.ts b/src/types/financing.ts new file mode 100644 index 0000000..126ba9f --- /dev/null +++ b/src/types/financing.ts @@ -0,0 +1,121 @@ +/** + * Invoice financing domain types. + * + * Supports creators requesting advance payments against outstanding invoices, + * an approval workflow, advance disbursement, fee calculation, and analytics. + */ + +export type FinancingApplicationStatus = + | 'draft' + | 'submitted' + | 'under_review' + | 'approved' + | 'rejected' + | 'disbursed' + | 'repaid' + | 'cancelled'; + +export type FinancingFeeType = 'flat' | 'percentage'; + +/** A single tier in the fee schedule, keyed by advance term in days. */ +export interface FinancingFeeTier { + /** Advance term in days (e.g. 30, 60, 90). */ + termDays: number; + /** Fee type applied for this tier. */ + feeType: FinancingFeeType; + /** Fee value: absolute amount for `flat`, or rate (0-1) for `percentage`. */ + feeValue: number; + /** Minimum fee charged regardless of computed amount. */ + minimumFee?: number; +} + +/** Financing product configuration offered to creators. */ +export interface FinancingProduct { + id: string; + name: string; + description?: string; + /** Fraction of the invoice amount advanced up front (0-1). */ + advanceRate: number; + /** Minimum invoice amount eligible for financing. */ + minimumInvoiceAmount: number; + /** Maximum invoice amount eligible for financing. */ + maximumInvoiceAmount: number; + /** Allowed advance terms in days. */ + allowedTerms: number[]; + /** Fee schedule applied based on the selected term. */ + feeSchedule: FinancingFeeTier[]; + /** Whether the product is currently available for new applications. */ + active: boolean; +} + +/** Eligibility result for a creator against a financing product. */ +export interface FinancingEligibility { + eligible: boolean; + productId: string; + /** Human-readable reasons when not eligible. */ + reasons: string[]; + /** Maximum advance amount the creator qualifies for. */ + maxAdvanceAmount?: number; +} + +/** A creator's application for an advance on a specific invoice. */ +export interface FinancingApplication { + id: string; + creatorId: string; + invoiceId: string; + productId: string; + /** Requested advance amount. */ + requestedAmount: number; + /** Selected advance term in days. */ + termDays: number; + status: FinancingApplicationStatus; + /** Computed fee for the application. */ + feeAmount?: number; + /** Net amount disbursed after fees. */ + netDisbursement?: number; + /** Reviewer who approved or rejected the application. */ + reviewedBy?: string; + /** Reason provided on rejection. */ + rejectionReason?: string; + createdAt: string; + updatedAt: string; + submittedAt?: string; + approvedAt?: string; + disbursedAt?: string; +} + +/** Result of a fee calculation for a given advance. */ +export interface FinancingFeeCalculation { + advanceAmount: number; + termDays: number; + feeAmount: number; + netDisbursement: number; + feeTier: FinancingFeeTier; +} + +/** Record of an advance disbursement tied to an approved application. */ +export interface FinancingDisbursement { + id: string; + applicationId: string; + creatorId: string; + invoiceId: string; + amount: number; + feeAmount: number; + netAmount: number; + status: 'pending' | 'completed' | 'failed'; + disbursedAt?: string; + createdAt: string; +} + +/** Aggregated financing analytics for reporting. */ +export interface FinancingAnalytics { + totalApplications: number; + approvedApplications: number; + rejectedApplications: number; + disbursedApplications: number; + totalAdvancedAmount: number; + totalFeesCollected: number; + averageAdvanceAmount: number; + averageTermDays: number; + approvalRate: number; +} From a195fe9ecc01b5c48a8f0baa3804667aab0e00cb Mon Sep 17 00:00:00 2001 From: Tisan Date: Thu, 22 Oct 2026 08:18:46 +0000 Subject: [PATCH 2/4] fix: #978 Implement payment time-lock functionality Closes #978 --- docs/CONTRACTS_IMPLEMENTATION.md | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/CONTRACTS_IMPLEMENTATION.md b/docs/CONTRACTS_IMPLEMENTATION.md index 5debf85..0774315 100644 --- a/docs/CONTRACTS_IMPLEMENTATION.md +++ b/docs/CONTRACTS_IMPLEMENTATION.md @@ -91,6 +91,56 @@ pub struct CreatorStats { Maintain a set of unique payers per creator to accurately count `total_payers`. +## Issue #978: Payment Time-Lock Functionality + +**Location**: `split-contracts/src/types.rs` and main contract file + +### Implementation Tasks + +1. **Add TimeLock struct** in `types.rs`: +```rust +pub enum LockStatus { + Locked, + Released, + EarlyReleased, +} + +pub struct TimeLock { + pub release_at: u64, // ledger timestamp when funds unlock + pub early_penalty_bps: u32, // penalty applied on early release + pub status: LockStatus, +} +``` + +2. **Lock creation**: When a payment/invoice is created with a lock, store `TimeLock` keyed by payment id. Validate `early_penalty_bps <= 10000` and `release_at > current ledger timestamp`. + +3. **Lock validation** `assert_unlocked(payment_id)`: + - Reject access while `status == Locked` and `now < release_at` + - Allow access once `now >= release_at` (automatic release) + +4. **Early access** `release_early(payment_id)`: + - Only callable by the payment recipient/creator + - Compute `penalty = amount * early_penalty_bps / 10000` + - Transfer `amount - penalty` to recipient, penalty to treasury + - Set `status = EarlyReleased`, emit `payment_early_released { payment_id, penalty }` + +5. **Automatic release**: On any access after `release_at`, transition `Locked -> Released` and emit `payment_released { payment_id }`. + +6. **Status tracking**: Provide `get_lock_status(payment_id) -> LockStatus` read function. + +7. **Analytics**: Track `total_locked`, `total_released`, `total_early_released`, and `total_penalties_collected` per creator; expose `get_lock_analytics(creator) -> LockAnalytics`. + +8. **Events**: + - `payment_locked { payment_id, release_at }` + - `payment_released { payment_id }` + - `payment_early_released { payment_id, penalty }` + +### Access Control + +- Only the payment recipient may trigger early release +- Locked payments must never be released or withdrawn before `release_at` except via `release_early` +- Enforce `debug_assert!(released_amount <= funded)` after every release + ## Testing Strategy Each implementation should include: From 8f868cbf2d60e4f8bdf16954353396a5ed6a9830 Mon Sep 17 00:00:00 2001 From: Tisan Date: Thu, 22 Oct 2026 08:18:59 +0000 Subject: [PATCH 3/4] fix: #979 Add payment aggregation across multiple payment methods Closes #979 --- .../AdvancedPaymentMethodPicker.tsx | 164 +++++++++++++----- src/types/payment.ts | 77 ++++++++ 2 files changed, 202 insertions(+), 39 deletions(-) create mode 100644 src/types/payment.ts diff --git a/src/components/AdvancedPaymentMethodPicker.tsx b/src/components/AdvancedPaymentMethodPicker.tsx index 5a4e256..309482a 100644 --- a/src/components/AdvancedPaymentMethodPicker.tsx +++ b/src/components/AdvancedPaymentMethodPicker.tsx @@ -49,16 +49,78 @@ export const DEFAULT_PAYMENT_METHODS: PaymentMethodOption[] = [ }, ]; +/** A single payment contribution from one method. */ +export interface PaymentContribution { + method: PaymentMethodId; + /** Amount in the smallest unit (e.g. stroops) to keep aggregation exact. */ + amount: number; + /** Optional fee in the smallest unit; defaults to 0. */ + fee?: number; +} + +/** Aggregated totals for a single payment method. */ +export interface MethodAggregate { + method: PaymentMethodId; + count: number; + amount: number; + fee: number; +} + +/** Consolidated aggregation across all payment methods. */ +export interface PaymentAggregation { + byMethod: MethodAggregate[]; + totalAmount: number; + totalFee: number; + netAmount: number; + count: number; +} + +/** + * Aggregate payments across multiple methods, grouping by method and + * consolidating totals. Methods with no contributions are omitted. + */ +export function aggregatePayments(contributions: PaymentContribution[]): PaymentAggregation { + const groups = new Map(); + for (const c of contributions) { + const amount = Number.isFinite(c.amount) ? c.amount : 0; + const fee = Number.isFinite(c.fee) ? (c.fee as number) : 0; + const existing = groups.get(c.method); + if (existing) { + existing.count += 1; + existing.amount += amount; + existing.fee += fee; + } else { + groups.set(c.method, { method: c.method, count: 1, amount, fee }); + } + } + + const byMethod = Array.from(groups.values()); + const totalAmount = byMethod.reduce((sum, g) => sum + g.amount, 0); + const totalFee = byMethod.reduce((sum, g) => sum + g.fee, 0); + const count = byMethod.reduce((sum, g) => sum + g.count, 0); + + return { + byMethod, + totalAmount, + totalFee, + netAmount: totalAmount - totalFee, + count, + }; +} + interface Props { value: PaymentMethodId | null; onChange: (id: PaymentMethodId) => void; methods?: PaymentMethodOption[]; + /** Optional contributions to render a consolidated aggregation summary. */ + contributions?: PaymentContribution[]; } export default function AdvancedPaymentMethodPicker({ value, onChange, methods = DEFAULT_PAYMENT_METHODS, + contributions, }: Props) { const refs = useRef<(HTMLButtonElement | null)[]>([]); const enabled = methods.map((m, i) => (m.disabled ? -1 : i)).filter((i) => i >= 0); @@ -66,6 +128,7 @@ export default function AdvancedPaymentMethodPicker({ methods.findIndex((m) => m.id === value && !m.disabled), enabled[0] ?? 0 ); + const aggregation = contributions && contributions.length > 0 ? aggregatePayments(contributions) : null; const onKeyDown = (e: React.KeyboardEvent, index: number) => { const dir = @@ -79,46 +142,69 @@ export default function AdvancedPaymentMethodPicker({ }; return ( -
- {methods.map((m, i) => { - const selected = value === m.id; - return ( -
-

{m.description}

-
- Fee: {m.fee} - Time: {m.eta} -
- {m.disabled && m.disabledReason && ( -

{m.disabledReason}

- )} - - ); - })} + + ); + })} + + + {aggregation && ( +
+

Aggregated payments

+
    + {aggregation.byMethod.map((g) => ( +
  • + {methods.find((m) => m.id === g.method)?.label ?? g.method} + + {g.count} × {g.amount} (fee {g.fee}) + +
  • + ))} +
+
+ Total: {aggregation.totalAmount} + Fees: {aggregation.totalFee} + Net: {aggregation.netAmount} +
+
+ )} ); } diff --git a/src/types/payment.ts b/src/types/payment.ts new file mode 100644 index 0000000..8ef61ef --- /dev/null +++ b/src/types/payment.ts @@ -0,0 +1,77 @@ +export type PaymentMethodType = + | 'card' + | 'bank_transfer' + | 'wallet' + | 'cash' + | 'crypto'; + +export interface PaymentMethod { + id: string; + type: PaymentMethodType; + label: string; + currency: string; + /** Fee rate applied to payments made with this method (e.g. 0.029 = 2.9%). */ + feeRate: number; + /** Flat fee per transaction for this method, in minor units. */ + flatFee: number; +} + +export interface Payment { + id: string; + methodId: string; + amount: number; + currency: string; + status: 'pending' | 'completed' | 'failed' | 'refunded'; + createdAt: string; +} + +/** Aggregated totals for a single payment method. */ +export interface MethodAggregate { + methodId: string; + type: PaymentMethodType; + currency: string; + paymentCount: number; + grossAmount: number; + totalFees: number; + netAmount: number; +} + +/** Consolidated balance across all methods for a currency. */ +export interface BalanceAggregate { + currency: string; + grossAmount: number; + totalFees: number; + netAmount: number; + paymentCount: number; +} + +/** Fee optimization suggestion for routing payments to cheaper methods. */ +export interface FeeOptimization { + methodId: string; + currentFeeRate: number; + suggestedFeeRate: number; + estimatedSavings: number; +} + +/** Consolidated report combining per-method and per-currency aggregation. */ +export interface PaymentAggregationReport { + generatedAt: string; + methods: MethodAggregate[]; + balances: BalanceAggregate[]; + optimizations: FeeOptimization[]; + analytics: PaymentAggregationAnalytics; +} + +/** Analytics derived from the aggregation. */ +export interface PaymentAggregationAnalytics { + totalPayments: number; + totalGross: number; + totalFees: number; + totalNet: number; + /** Effective fee rate across all aggregated payments. */ + effectiveFeeRate: number; + /** Method id with the highest gross volume. */ + topMethodId: string | null; + /** Method id with the lowest effective fee rate. */ + cheapestMethodId: string | null; +} From 5eb4cf0246ca80cefb24f431ad798892e9c35d1e Mon Sep 17 00:00:00 2001 From: Tisan Date: Thu, 22 Oct 2026 08:19:18 +0000 Subject: [PATCH 4/4] fix: #980 Implement invoice workflow automation engine Closes #980 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index e85e508..15bd24a 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,6 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full guide. - #991: Implement custom report builder and scheduling + + +- #980: Implement invoice workflow automation engine