Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions packages/contracts/CHARTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,11 @@ These exist so the template stays a **template**, not a half-finished product:

| Metric | Cap | Rationale |
|---|---|---|
| Business objects | ≤ 4 (`party`, `contract`, `obligation`, `contract_party_link`) | Readable in one sitting |
| Total `.ts` LOC under `src/` | ≤ 2,500 | Same |
| Locales | 1 (`en`) | Forkable starting point |
| Dashboards | 1 | "Renewals at risk" |
| Flows | ≤ 3 | Renewal alert + signed → create obligations + assigned notify |
| Business objects | ≤ 4 (`party`, `contract`, `obligation`; a `contract_party_link` junction for multi-party deals remains a documented fork) | Readable in one sitting |
| Total `.ts` LOC under `src/` | ≤ 3,000 | Raised 2,500→3,000 (2026-06) for approval enforcement, currency, renewal-decision capture, the signed→obligations flow, and the exposure-by-counterparty widget. |
| Locales | 2 (`en`, `zh-CN`) | zh-CN ships repo-wide (PR #13); en remains the forkable base |
| Dashboards | 1 | "Renewals at risk" (now incl. exposure-by-counterparty) |
| Flows | ≤ 4 | Renewal alert + renewal draft + signed→obligations + obligation overdue. Raised 3→4 to ship the charter's promised signed→obligations flow. |
| AI actions | 1 | `extract_terms` — others belong in user forks |
| Permission sets | 2 | `contract_owner` + `legal_admin` |
| Sharing rules | 0 at v0 | Profiles + roles cover the demo; rules are a fork extension |
Expand Down
21 changes: 20 additions & 1 deletion packages/contracts/src/dashboards/renewals_at_risk.dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,26 @@ export const RenewalsAtRiskDashboard: Dashboard = {
showLegend: true,
showDataLabels: false,
},
layout: { x: 0, y: 7, w: 12, h: 5 },
layout: { x: 0, y: 7, w: 7, h: 5 },
},
{
id: 'exposure_by_counterparty',
dataset: 'contracts_contract_metrics',
dimensions: ['party'],
values: ['sum_total_value'],
title: 'Active Exposure by Counterparty',
type: 'bar',
filter: { status: 'active' },
chartConfig: {
type: 'bar',
xAxis: { field: 'party', showGridLines: false, logarithmic: false },
yAxis: [
{ field: 'sum_total_value', format: '$0,0', showGridLines: true, logarithmic: false },
],
showLegend: false,
showDataLabels: false,
},
layout: { x: 7, y: 7, w: 5, h: 5 },
},
],
};
2 changes: 2 additions & 0 deletions packages/contracts/src/datasets/contracts_contract.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const ContractsContractDataset = defineDataset({
type: 'date',
dateGranularity: 'month',
},
{ name: 'party', label: 'party', field: 'party', type: 'string' },
{ name: 'contract_type', label: 'contract_type', field: 'contract_type', type: 'string' },
],
measures: [
{ name: 'contract_count', label: 'contract_count', aggregate: 'count' },
Expand Down
8 changes: 7 additions & 1 deletion packages/contracts/src/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

import { ContractRenewalAlertFlow } from './renewal_alert.flow';
import { ContractRenewalDraftFlow } from './renewal_draft.flow';
import { ContractSignedObligationsFlow } from './signed_obligations.flow';
import { ObligationOverdueFlow } from './obligation_overdue.flow';

export const allFlows = [ContractRenewalAlertFlow, ContractRenewalDraftFlow, ObligationOverdueFlow];
export const allFlows = [
ContractRenewalAlertFlow,
ContractRenewalDraftFlow,
ContractSignedObligationsFlow,
ObligationOverdueFlow,
];
88 changes: 88 additions & 0 deletions packages/contracts/src/flows/signed_obligations.flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.

import type * as Automation from '@objectstack/spec/automation';
type Flow = Automation.Flow;

/**
* Signed → Obligations. When a contract is signed, drop a renewal-notice
* obligation into the tracker so the team never misses the window to decide
* renew vs. terminate. This is the charter's "signed → create obligations"
* flow: it turns post-signature metadata into a dated, assignable task.
*
* Fires once, on the transition into "signed" (guarded by `previous.status`),
* and only when an `end_date` exists (the notice obligation needs a due date).
* Kind = "notice" so no amount is required. A fork can fan out additional
* obligations from `extracted_clauses` (payment schedule, SOC2 report, …) and
* compute `end_date − renewal_notice_days` once date arithmetic lands in CEL.
*/
export const ContractSignedObligationsFlow: Flow = {
name: 'contracts_contract_signed_obligations',
label: 'Create Obligations on Signing',
description:
'On signing, create a renewal-notice obligation due by the end date so the renewal decision is tracked.',
type: 'record_change',

variables: [{ name: 'contractId', type: 'text', isInput: true, isOutput: false }],

nodes: [
{
id: 'start',
type: 'start',
label: 'Start',
config: {
objectName: 'contracts_contract',
triggerType: 'record-after-update',
condition: 'status == "signed" && previous.status != "signed" && end_date != null',
},
},
{
id: 'get_parent',
type: 'get_record',
label: 'Load Contract',
config: {
objectName: 'contracts_contract',
filter: { id: '{record.id}' },
outputVariable: 'parent',
},
},
{
id: 'create_notice_obligation',
type: 'create_record',
label: 'Create Renewal-Notice Obligation',
config: {
objectName: 'contracts_obligation',
fields: {
summary: 'Decide renewal — {parent.title}',
contract: '{parent.id}',
obligor: 'us',
kind: 'notice',
status: 'open',
due_date: '{parent.end_date}',
assignee: '{parent.owner}',
notes:
'Auto-created on signing. Review renew/renegotiate/terminate before the end date (account for the renewal-notice window).',
},
outputVariable: 'obligation',
},
},
{
id: 'notify_owner',
type: 'notify',
label: 'Notify Owner',
config: {
recipients: ['{parent.owner}'],
title: 'Renewal obligation created: {parent.title}',
body: 'A renewal-decision obligation was added for "{parent.title}" (end date {parent.end_date}).',
actionUrl: '/objects/contracts_obligation/{obligation.id}',
},
},
{ id: 'end', type: 'end', label: 'End' },
],

edges: [
{ id: 'e1', source: 'start', target: 'get_parent', type: 'default' },
{ id: 'e2', source: 'get_parent', target: 'create_notice_obligation', type: 'default' },
{ id: 'e3', source: 'create_notice_obligation', target: 'notify_owner', type: 'default' },
{ id: 'e4', source: 'notify_owner', target: 'end', type: 'default' },
],
};
10 changes: 10 additions & 0 deletions packages/contracts/src/objects/contracts_contract.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ const contractHook: Hook = {
}
}

// Stamp approved_at the moment an approver is first recorded.
const prevApprover = previous?.approver ?? null;
if (input.approver != null && prevApprover == null && input.approved_at == null) {
input.approved_at = new Date().toISOString();
}
// Clear the stamp if the approver is removed.
if (event === 'beforeUpdate' && input.approver === null) {
input.approved_at = null;
}

// Auto-renew sanity: zero notice days are nonsensical; default 30.
if (input.auto_renew === true && input.renewal_notice_days == null) {
input.renewal_notice_days = 30;
Expand Down
55 changes: 54 additions & 1 deletion packages/contracts/src/objects/contracts_contract.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,20 @@ export const Contract = ObjectSchema.create({
label: 'Total Value',
group: 'commercial',
min: 0,
description: 'Annualised if recurring; total if one-off. Currency: USD (override per-fork).',
description: 'Annualised if recurring; total if one-off. Denominated in `currency`.',
}),

currency: Field.select({
label: 'Currency',
group: 'commercial',
description: 'ISO-4217 currency of total_value. Populated by extract_terms when detectable.',
options: [
{ label: 'USD', value: 'USD', default: true },
{ label: 'EUR', value: 'EUR' },
{ label: 'GBP', value: 'GBP' },
{ label: 'CNY', value: 'CNY' },
{ label: 'JPY', value: 'JPY' },
],
}),

payment_terms: Field.select({
Expand Down Expand Up @@ -137,6 +150,22 @@ export const Contract = ObjectSchema.create({
description:
'Days before end_date by which we must notify to cancel. Drives alert lead time.',
}),
renewal_decision: Field.select({
label: 'Renewal Decision',
group: 'renewal',
description: 'The team’s decision for this term — closes the renewal loop.',
options: [
{ label: 'Pending', value: 'pending', color: '#94A3B8', default: true },
{ label: 'Renew', value: 'renew', color: '#10B981' },
{ label: 'Renegotiate', value: 'renegotiate', color: '#F59E0B' },
{ label: 'Let Expire / Terminate', value: 'terminate', color: '#EF4444' },
],
}),
renewal_decision_due: Field.date({
label: 'Decision Due',
group: 'renewal',
description: 'When the renew/terminate call must be made (usually end_date − notice).',
}),

// Derived signals
//
Expand Down Expand Up @@ -165,6 +194,19 @@ export const Contract = ObjectSchema.create({
expression: F`record.total_value != null && record.total_value >= 50000`,
}),

approver: Field.lookup('sys_user', {
label: 'Approver',
group: 'commercial',
description:
'Who signed off the commercials. Required before a ≥ $50k contract can move to "signed".',
}),
approved_at: Field.datetime({
label: 'Approved At',
group: 'commercial',
readonly: true,
description: 'Stamped by the hook when an approver is recorded.',
}),

// AI-extracted (rewritten by extract_terms.action.ts)
extracted_clauses: Field.json({
label: 'Extracted Clauses',
Expand Down Expand Up @@ -256,6 +298,17 @@ export const Contract = ObjectSchema.create({
message: 'Status "signed" requires a signed_date.',
condition: P`record.status == "signed" && record.signed_date == null`,
},
{
// Warning (not error): surfaces the missing sign-off on the form without
// blocking data loads (seed can't set a real `approver` user) or wedging
// the lifecycle. Hard, routed enforcement is a fork onto the native
// approval engine — see CHARTER "approvals".
name: 'high_value_requires_approver_before_signing',
type: 'script',
severity: 'warning',
message: 'Contracts of $50k or more should record an approver before signing.',
condition: P`record.total_value != null && record.total_value >= 50000 && record.approver == null && (record.status == "signed" || record.status == "active")`,
},
],

// signed_date stamping + the date-driven activate/expire transitions live
Expand Down
16 changes: 16 additions & 0 deletions packages/contracts/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export const en: TranslationData = {
},
owner: { label: 'Internal Owner' },
total_value: { label: 'Total Value' },
currency: {
label: 'Currency',
options: { USD: 'USD', EUR: 'EUR', GBP: 'GBP', CNY: 'CNY', JPY: 'JPY' },
},
payment_terms: {
label: 'Payment Terms',
options: {
Expand All @@ -64,9 +68,21 @@ export const en: TranslationData = {
signed_date: { label: 'Signed Date' },
auto_renew: { label: 'Auto-Renew' },
renewal_notice_days: { label: 'Renewal Notice (days)' },
renewal_decision: {
label: 'Renewal Decision',
options: {
pending: 'Pending',
renew: 'Renew',
renegotiate: 'Renegotiate',
terminate: 'Let Expire / Terminate',
},
},
renewal_decision_due: { label: 'Decision Due' },
is_expiring_soon: { label: 'Expiring ≤ 60 days' },
is_auto_renewing_soon: { label: 'Auto-Renewing ≤ 30 days' },
approval_required: { label: 'Approval Required' },
approver: { label: 'Approver' },
approved_at: { label: 'Approved At' },
extracted_clauses: { label: 'Extracted Clauses' },
extraction_confidence: { label: 'Extraction Confidence' },
extracted_at: { label: 'Extracted At' },
Expand Down
16 changes: 16 additions & 0 deletions packages/contracts/src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export const zhCN: TranslationData = {
},
owner: { label: '内部负责人' },
total_value: { label: '合同金额' },
currency: {
label: '币种',
options: { USD: '美元', EUR: '欧元', GBP: '英镑', CNY: '人民币', JPY: '日元' },
},
payment_terms: {
label: '付款条件',
options: {
Expand All @@ -63,9 +67,21 @@ export const zhCN: TranslationData = {
signed_date: { label: '签署日期' },
auto_renew: { label: '自动续约' },
renewal_notice_days: { label: '续约通知期 (天)' },
renewal_decision: {
label: '续约决定',
options: {
pending: '待定',
renew: '续约',
renegotiate: '重新谈判',
terminate: '到期终止',
},
},
renewal_decision_due: { label: '决定截止日' },
is_expiring_soon: { label: '60 天内到期' },
is_auto_renewing_soon: { label: '30 天内自动续约' },
approval_required: { label: '需审批' },
approver: { label: '审批人' },
approved_at: { label: '审批时间' },
extracted_clauses: { label: 'AI 抽取条款' },
extraction_confidence: { label: '抽取置信度' },
extracted_at: { label: '抽取时间' },
Expand Down
Loading