@alvera-ai/platform-sdk 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/AGENTS.md +82 -144
- package/.agent/account_management.md +2 -2
- package/.agent/action_logs.md +4 -4
- package/.agent/ai_agents.md +28 -21
- package/.agent/ai_sandbox.md +49 -39
- package/.agent/connected_apps.md +3 -3
- package/.agent/cookbook/_fixtures/README.md +1 -1
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_generic_table.liquid +1 -1
- package/.agent/cookbook/_fixtures/organic-marketing/_lead_submissions_foundation_legal_entity.liquid +80 -0
- package/.agent/cookbook/_fixtures/{foundation → organic-marketing}/_lead_submissions_foundation_mdm.liquid +2 -1
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_legal_entity.liquid +30 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_compliance_screenings_mdm.liquid +44 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_generic_table.liquid +57 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_legal_entity.liquid +36 -0
- package/.agent/cookbook/_fixtures/payments-compliance/_payment_accounts_mdm.liquid +41 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_generic_table.liquid +70 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_legal_entity.liquid +52 -0
- package/.agent/cookbook/_fixtures/primary-care-feedback/_cahps_appointments_mdm.liquid +42 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_generic_table.liquid +38 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_legal_entity.liquid +48 -0
- package/.agent/cookbook/_fixtures/subscription-saas/_customers_subscription_mdm.liquid +49 -0
- package/.agent/cookbook/organic-marketing.md +2801 -0
- package/.agent/cookbook/payments-compliance.md +2180 -0
- package/.agent/cookbook/primary-care.md +2175 -0
- package/.agent/cookbook/subscription-saas.md +2403 -0
- package/.agent/data_activation_clients.md +65 -52
- package/.agent/datalakes.md +338 -171
- package/.agent/errors.md +3 -3
- package/.agent/generic_tables.md +151 -62
- package/.agent/interoperability_contracts.md +57 -22
- package/.agent/mdm.md +136 -153
- package/.agent/messages.md +36 -34
- package/.agent/mock-services.md +1 -1
- package/.agent/mutations.md +2 -2
- package/.agent/templates.md +14 -13
- package/.agent/tools.md +63 -21
- package/.agent/type_naming.md +13 -13
- package/.agent/workflows.md +99 -53
- package/README.md +2 -2
- package/dist/bin/platform-sdk.mjs +33 -47
- package/dist/bin/platform-sdk.mjs.map +1 -1
- package/dist/index.d.mts +565 -379
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +494 -59
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/.agent/cookbook/_fixtures/foundation/_lead_submissions_foundation_legal_entity.liquid +0 -88
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_appointment.liquid +0 -47
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_mdm.liquid +0 -24
- package/.agent/cookbook/_fixtures/healthcare/_cahps_appointments_healthcare_patient.liquid +0 -38
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_compliance_screening.liquid +0 -59
- package/.agent/cookbook/_fixtures/payments/_compliance_screenings_payments_mdm.liquid +0 -36
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_mdm.liquid +0 -30
- package/.agent/cookbook/_fixtures/payments/_payment_accounts_payments_payment_account.liquid +0 -55
- package/.agent/cookbook/_fixtures/subscription/_customers_subscription_mdm.liquid +0 -20
- package/.agent/cookbook/_setup/foundation.md +0 -359
- package/.agent/cookbook/_setup/healthcare.md +0 -361
- package/.agent/cookbook/_setup/payments.md +0 -365
- package/.agent/cookbook/_setup/subscription.md +0 -364
- package/.agent/cookbook/action-status-updaters.md +0 -278
- package/.agent/cookbook/ai-agent-invoke.md +0 -279
- package/.agent/cookbook/appointment-review-sms-workflow.md +0 -801
- package/.agent/cookbook/birthday-greeting-sms-trigger.md +0 -696
- package/.agent/cookbook/bulk-ingest.md +0 -302
- package/.agent/cookbook/contact-us-triage-with-llm.md +0 -663
- package/.agent/cookbook/dunning-sms-for-delinquent.md +0 -659
- package/.agent/cookbook/generic-tables.md +0 -244
- package/.agent/cookbook/invite-team.md +0 -200
- package/.agent/cookbook/kyc-notification-on-account-activation.md +0 -661
- package/.agent/cookbook/marketing-campaign-send.md +0 -1044
- package/.agent/cookbook/paginated-restapi-poller.md +0 -383
- package/.agent/cookbook/rest-fetch.md +0 -273
- package/.agent/cookbook/sanctions-screening-with-agent-review.md +0 -773
- package/.agent/cookbook/score-leads-with-llm-categorization.md +0 -665
- package/.agent/cookbook/system-templates.md +0 -165
- package/.agent/cookbook/talk-to-data.md +0 -178
- package/.agent/cookbook/triage-prospects-by-priority.md +0 -571
- package/.agent/cookbook/welcome-sms-for-customers.md +0 -647
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/memorandum-of-association-01.png +0 -0
- /package/.agent/cookbook/_fixtures/{healthcare → primary-care-feedback}/sample_two_page.pdf +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/_customers_subscription_customer.liquid +0 -0
- /package/.agent/cookbook/_fixtures/{subscription → subscription-saas}/stripe_customers_batch1.csv +0 -0
|
@@ -1,659 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Send a payment-reminder SMS to delinquent customers with a pay-invoice link
|
|
3
|
-
summary: End-to-end standard workflow — provision a Dunning SMS workflow over the AR customer dataset, ingest two Stripe customer rows (one phone + tax-id verified, one missing tax-id) through a customer interop contract, run the workflow so the SMS-reachable-and-KYC-complete filter routes each row, read the rendered SMS back from the message dataset, and close the connected-app reply loop. No agent, no LLM.
|
|
4
|
-
industry: subscription
|
|
5
|
-
slug: dunning-sms-for-delinquent
|
|
6
|
-
vitest_source:
|
|
7
|
-
- integration-tests/tests/subscription/dunning-sms-workflow.test.ts
|
|
8
|
-
- integration-tests/tests/subscription/interoperability-contracts.test.ts
|
|
9
|
-
- integration-tests/tests/subscription/run-dac-single.test.ts
|
|
10
|
-
- integration-tests/tests/subscription/create-dac.test.ts
|
|
11
|
-
- integration-tests/tests/subscription/tools.test.ts
|
|
12
|
-
- integration-tests/tests/subscription/data-sources.test.ts
|
|
13
|
-
- integration-tests/tests/subscription/bootstrap.test.ts
|
|
14
|
-
status: green
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
# Problem
|
|
18
|
-
|
|
19
|
-
When an invoice goes unpaid, the billing team wants to send a
|
|
20
|
-
payment-reminder ("dunning") SMS with a self-serve pay link — but
|
|
21
|
-
only to customers it is both *allowed* and *able* to contact. The
|
|
22
|
-
customer must be SMS-reachable (a phone on file) and KYC-complete
|
|
23
|
-
(a verified tax id). A customer missing either precondition should
|
|
24
|
-
be skipped, not errored, and certainly not chased.
|
|
25
|
-
|
|
26
|
-
The classic implementation queries the customers table, branches
|
|
27
|
-
on phone-present and tax-id-present, and hands the survivors to an
|
|
28
|
-
SMS sender — with both preconditions living in application code.
|
|
29
|
-
|
|
30
|
-
The Alvera platform's standard workflow primitive keeps both
|
|
31
|
-
checks inside the platform. An AR `customer` dataset is the
|
|
32
|
-
workflow's event source; a pure-Liquid filter gates on
|
|
33
|
-
`customer.phone and customer.tax_id`; a single SMS action carries
|
|
34
|
-
a deep-link to a connected-app payment portal. The
|
|
35
|
-
reachable-and-verified decision is a one-line filter, not a code
|
|
36
|
-
branch.
|
|
37
|
-
|
|
38
|
-
This cookbook walks the **whole** scenario, not just the
|
|
39
|
-
provisioning: it creates the workflow, ingests two Stripe-shaped
|
|
40
|
-
customer rows through the production data-activation chain (one
|
|
41
|
-
phone + tax-id verified, one missing the tax id), runs the
|
|
42
|
-
workflow so the filter routes each row, reads the rendered dunning
|
|
43
|
-
SMS back out of the message dataset, and finally resolves the
|
|
44
|
-
connected-app deep-link the SMS carries — closing the SMS → reply
|
|
45
|
-
loop.
|
|
46
|
-
|
|
47
|
-
The scenario is anchored to
|
|
48
|
-
`platform/integration-tests/tests/subscription/dunning-sms-workflow.test.ts`
|
|
49
|
-
— a green end-to-end test (§1–§8) that exercises this exact shape.
|
|
50
|
-
This cookbook is a prose re-presentation of what that test walks.
|
|
51
|
-
The setup file `_setup/subscription.md` already provisioned
|
|
52
|
-
the tenant, datalake, and industry-admin client; this cookbook
|
|
53
|
-
starts from there.
|
|
54
|
-
|
|
55
|
-
# Composition
|
|
56
|
-
|
|
57
|
-
| Resource provisioned | Owner |
|
|
58
|
-
|----------------------------------|-------------|
|
|
59
|
-
| SMS tool (SNS-backed) | build |
|
|
60
|
-
| Dunning payment-portal connected app | build |
|
|
61
|
-
| Dunning SMS workflow | build |
|
|
62
|
-
| Stripe data source | build |
|
|
63
|
-
| Manual Upload tool | build |
|
|
64
|
-
| Stripe Customer interop contract | build |
|
|
65
|
-
| Manual-upload DAC | build |
|
|
66
|
-
|
|
67
|
-
The setup file `_setup/subscription.md` already
|
|
68
|
-
provisioned the tenant + datalake + tenant-scoped client; this
|
|
69
|
-
cookbook starts from there.
|
|
70
|
-
|
|
71
|
-
# Walkthrough
|
|
72
|
-
|
|
73
|
-
## 001 — create the SMS tool
|
|
74
|
-
|
|
75
|
-
The Dunning SMS workflow's action invokes an SMS tool. The tool's
|
|
76
|
-
`body.tool_body_type: 'sns'` means it routes via AWS SNS; local
|
|
77
|
-
dev points it at LocalStack on `http://localhost:4566` via
|
|
78
|
-
`endpoint_url` so no real AWS credentials are needed. The
|
|
79
|
-
`intent: 'sms'` tags this tool for workflow actions that send SMS
|
|
80
|
-
(versus `data_exchange` for ingestion tools).
|
|
81
|
-
|
|
82
|
-
```typescript
|
|
83
|
-
const smsToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
84
|
-
name: `Cookbook SMS Tool ${runSuffix}`,
|
|
85
|
-
description: 'SNS-backed SMS dispatcher for the Dunning SMS workflow, wired to LocalStack.',
|
|
86
|
-
intent: 'sms',
|
|
87
|
-
status: 'active',
|
|
88
|
-
datalake_id: ctx.datalakeId,
|
|
89
|
-
body: {
|
|
90
|
-
tool_body_type: 'sns',
|
|
91
|
-
auth_method: 'access_key',
|
|
92
|
-
region: 'us-east-1',
|
|
93
|
-
phone_number: '+15551234567',
|
|
94
|
-
endpoint_url: 'http://localhost:4566',
|
|
95
|
-
access_key_id: 'test',
|
|
96
|
-
secret_access_key: 'test',
|
|
97
|
-
},
|
|
98
|
-
})
|
|
99
|
-
toolId = smsToolResp.data.id!
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
## 002 — create the Dunning payment-portal connected app
|
|
103
|
-
|
|
104
|
-
The workflow's SMS action carries a deep-link to a connected-app
|
|
105
|
-
page so the customer can open the self-serve payment portal. The
|
|
106
|
-
connected app is a thin registration of the portal's URL and
|
|
107
|
-
mode; the actual page is hosted outside the platform
|
|
108
|
-
(`mode: 'self_hosted'`). The server-derived `slug` is captured for
|
|
109
|
-
the resolve-page call in §013.
|
|
110
|
-
|
|
111
|
-
```typescript
|
|
112
|
-
const connectedAppResp = await api.connectedApps.create(tenantSlug, datalakeSlug, {
|
|
113
|
-
name: `Cookbook Dunning Payment Portal ${runSuffix}`,
|
|
114
|
-
description: 'Self-serve invoice payment portal linked from the outbound dunning SMS.',
|
|
115
|
-
mode: 'self_hosted',
|
|
116
|
-
urls: [
|
|
117
|
-
{
|
|
118
|
-
url: 'https://dunning.example.local',
|
|
119
|
-
is_primary: true,
|
|
120
|
-
label: 'production',
|
|
121
|
-
},
|
|
122
|
-
],
|
|
123
|
-
})
|
|
124
|
-
connectedAppId = connectedAppResp.data.id!
|
|
125
|
-
ctx.connectedAppSlug = connectedAppResp.data.slug!
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
## 003 — create the Dunning SMS workflow
|
|
129
|
-
|
|
130
|
-
The workflow has the standard shape: a filter (Liquid; passes
|
|
131
|
-
customers who are both SMS-reachable *and* KYC-complete —
|
|
132
|
-
`customer.phone and customer.tax_id`), a decision (a literal
|
|
133
|
-
Liquid array naming one decision key), and one SMS action. The
|
|
134
|
-
SMS body references `{{ customer.customer_number }}` so the
|
|
135
|
-
recipient sees which account is overdue, and
|
|
136
|
-
`{{ connected_app_form_url }}` so the platform can bake in the
|
|
137
|
-
`/t/<token>` pay-link.
|
|
138
|
-
|
|
139
|
-
Two details matter. `dataset_type: 'customer'` makes the AR
|
|
140
|
-
customer dataset the event source; the workflow context injects
|
|
141
|
-
the regulated customer row under the `customer` key, so the
|
|
142
|
-
filter reads `customer.phone` and `customer.tax_id` directly. A
|
|
143
|
-
`context_datasets` entry queries the `message` dataset for any
|
|
144
|
-
dunning SMS already sent to this customer in the last thirty days
|
|
145
|
-
— the platform's built-in guard against dunning the same customer
|
|
146
|
-
twice in a billing cycle. On a fresh tenant that lookup returns
|
|
147
|
-
empty, which is harmless.
|
|
148
|
-
|
|
149
|
-
```typescript
|
|
150
|
-
const FILTER_BODY = '{% if customer.phone and customer.tax_id %}true{% endif %}'
|
|
151
|
-
const DECISION_KEY = 'send_dunning_sms'
|
|
152
|
-
const DECISION_BODY = `["${DECISION_KEY}"]`
|
|
153
|
-
const DECISION_OUTPUT_SCHEMA = { type: 'array', items: { type: 'string' } }
|
|
154
|
-
const SMS_TO_TEMPLATE = '{{ mdm_output.regulated_customer.phone }}'
|
|
155
|
-
const SMS_BODY_TEMPLATE =
|
|
156
|
-
'Hi {{ mdm_output.regulated_customer.name }}, an invoice on account ' +
|
|
157
|
-
'{{ customer.customer_number }} needs your attention. ' +
|
|
158
|
-
'Pay now: {{ connected_app_form_url }}'
|
|
159
|
-
|
|
160
|
-
const workflowResp = await api.workflows.create(tenantSlug, datalakeSlug, {
|
|
161
|
-
name: `Cookbook Dunning SMS Workflow ${runSuffix}`,
|
|
162
|
-
description: 'Sends a payment-reminder SMS to delinquent contracted customers with a self-serve pay link.',
|
|
163
|
-
dataset_type: 'customer',
|
|
164
|
-
status: 'live',
|
|
165
|
-
tags: ['billing', 'dunning'],
|
|
166
|
-
filter_config: {
|
|
167
|
-
type: 'custom',
|
|
168
|
-
body: FILTER_BODY,
|
|
169
|
-
output_schema: { type: 'boolean' },
|
|
170
|
-
},
|
|
171
|
-
decision_config: {
|
|
172
|
-
type: 'custom',
|
|
173
|
-
body: DECISION_BODY,
|
|
174
|
-
output_schema: DECISION_OUTPUT_SCHEMA,
|
|
175
|
-
},
|
|
176
|
-
context_datasets: [
|
|
177
|
-
{
|
|
178
|
-
dataset_type: 'message',
|
|
179
|
-
where_clause:
|
|
180
|
-
`rm.customer_id = '{{ customer_id }}' AND rm.decision_key = '${DECISION_KEY}' ` +
|
|
181
|
-
"AND rm.sent_at > NOW() - INTERVAL '30 days'",
|
|
182
|
-
limit: 1,
|
|
183
|
-
position: 0,
|
|
184
|
-
},
|
|
185
|
-
],
|
|
186
|
-
actions: [
|
|
187
|
-
{
|
|
188
|
-
action_type: 'sms',
|
|
189
|
-
tool_id: toolId,
|
|
190
|
-
decision_key: DECISION_KEY,
|
|
191
|
-
position: 0,
|
|
192
|
-
trigger_template: 'now',
|
|
193
|
-
idempotency_template: '{{ customer_id }}-{{ decision_key }}',
|
|
194
|
-
connected_app_id: connectedAppId,
|
|
195
|
-
connected_app_route: '/portal/pay',
|
|
196
|
-
connected_app_metadata_template:
|
|
197
|
-
'{"customer_id":"{{ mdm_output.customer.id }}","customer_number":"{{ customer.customer_number }}"}',
|
|
198
|
-
tool_call: {
|
|
199
|
-
tool_call_type: 'sms_request',
|
|
200
|
-
to: { type: 'custom', body: SMS_TO_TEMPLATE },
|
|
201
|
-
body: { type: 'custom', body: SMS_BODY_TEMPLATE },
|
|
202
|
-
sms_type: 'transactional',
|
|
203
|
-
},
|
|
204
|
-
},
|
|
205
|
-
],
|
|
206
|
-
})
|
|
207
|
-
workflowId = workflowResp.data.id!
|
|
208
|
-
ctx.workflowSlug = workflowResp.data.slug!
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
## 004 — create the Stripe data source
|
|
212
|
-
|
|
213
|
-
A workflow runs on rows; rows arrive through the data-activation
|
|
214
|
-
chain. The chain's first link is a `DataSource` — a registration
|
|
215
|
-
of where the rows originate. The `uri` is the system-of-record
|
|
216
|
-
address; it flows into each ingested row's `source_uri`, which
|
|
217
|
-
the customer template renders onto the MDM identifier `system`
|
|
218
|
-
URN.
|
|
219
|
-
|
|
220
|
-
```typescript
|
|
221
|
-
const dataSourceResp = await api.dataSources.create(tenantSlug, datalakeSlug, {
|
|
222
|
-
name: `Cookbook Stripe Source ${runSuffix}`,
|
|
223
|
-
uri: 'stripe.example.com',
|
|
224
|
-
description: 'Stripe billing system — origin of the customer rows the dunning workflow runs on.',
|
|
225
|
-
status: 'active',
|
|
226
|
-
is_default: false,
|
|
227
|
-
})
|
|
228
|
-
dataSourceId = dataSourceResp.data.id!
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
## 005 — create the Manual Upload tool
|
|
232
|
-
|
|
233
|
-
The Data Activation Client needs a tool. For inline-JSON ingest a
|
|
234
|
-
`manual_upload` tool is the minimal choice — `intent:
|
|
235
|
-
'data_exchange'` distinguishes it from the SMS tool, and
|
|
236
|
-
`tool_body_type: 'manual_upload'` needs no endpoint or credential
|
|
237
|
-
wiring (the rows arrive in the ingest call body, not by the tool
|
|
238
|
-
fetching them).
|
|
239
|
-
|
|
240
|
-
```typescript
|
|
241
|
-
const manualUploadToolResp = await api.tools.create(tenantSlug, datalakeSlug, {
|
|
242
|
-
name: `Cookbook Manual Upload Tool ${runSuffix}`,
|
|
243
|
-
description: 'Manual-upload data-exchange tool — backs the DAC that ingests Stripe customer rows.',
|
|
244
|
-
intent: 'data_exchange',
|
|
245
|
-
status: 'active',
|
|
246
|
-
datalake_id: ctx.datalakeId,
|
|
247
|
-
data_source_id: dataSourceId,
|
|
248
|
-
body: { tool_body_type: 'manual_upload' },
|
|
249
|
-
})
|
|
250
|
-
ctx.manualUploadToolId = manualUploadToolResp.data.id!
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
## 006 — create the Stripe Customer interoperability contract
|
|
254
|
-
|
|
255
|
-
The interoperability contract is the row-shaping rule: a Liquid
|
|
256
|
-
template that maps an inbound Stripe customer row into an AR
|
|
257
|
-
`Customer` upsert. This cookbook loads the production Stripe
|
|
258
|
-
customer + MDM templates from the vendored fixtures directory
|
|
259
|
-
rather than inlining the Liquid.
|
|
260
|
-
|
|
261
|
-
Two template slots matter. `template_config` shapes the Customer
|
|
262
|
-
resource — including the nested `regulated_customer` object that
|
|
263
|
-
carries the PII fields (`name`, `email`, `phone`, `tax_id`).
|
|
264
|
-
`mdm_input_config` shapes the MDM input — the identifier
|
|
265
|
-
(`customer_number`), name, and contact fields the platform's
|
|
266
|
-
master-data resolution keys on to decide whether this is a new
|
|
267
|
-
customer or an existing one.
|
|
268
|
-
|
|
269
|
-
```typescript
|
|
270
|
-
const { readFileSync } = await import('node:fs')
|
|
271
|
-
const { join } = await import('node:path')
|
|
272
|
-
const customerTemplate = readFileSync(
|
|
273
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_customer.liquid'),
|
|
274
|
-
'utf8',
|
|
275
|
-
)
|
|
276
|
-
const mdmTemplate = readFileSync(
|
|
277
|
-
join(process.env.COOKBOOK_FIXTURES_DIR!, 'subscription/_customers_subscription_mdm.liquid'),
|
|
278
|
-
'utf8',
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
const contractResp = await api.interoperabilityContracts.create(tenantSlug, datalakeSlug, {
|
|
282
|
-
name: `Cookbook Stripe Customer Contract ${runSuffix}`,
|
|
283
|
-
description: 'Stripe customers → Subscription Customer (custom Liquid + MDM input).',
|
|
284
|
-
resource_type: 'customer',
|
|
285
|
-
template_config: { type: 'custom', body: customerTemplate },
|
|
286
|
-
mdm_input_config: { type: 'custom', body: mdmTemplate },
|
|
287
|
-
generic_table_id: null,
|
|
288
|
-
})
|
|
289
|
-
interopContractId = contractResp.data.id!
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
## 007 — create the manual-upload DAC
|
|
293
|
-
|
|
294
|
-
The Data Activation Client binds the three preceding pieces — the
|
|
295
|
-
manual-upload tool, the data source, and the interop contract —
|
|
296
|
-
into one ingestion endpoint. `tool_call.tool_call_type:
|
|
297
|
-
'manual_upload'` selects the inline-JSON ingest path. The
|
|
298
|
-
server-derived `slug` is the handle §008 ingests rows against.
|
|
299
|
-
|
|
300
|
-
```typescript
|
|
301
|
-
const dacResp = await api.dataActivationClients.create(tenantSlug, datalakeSlug, {
|
|
302
|
-
name: `Cookbook Stripe Customer DAC ${runSuffix}`,
|
|
303
|
-
description: 'Manual-upload DAC — ingests Stripe customer rows into Customer via the interop contract.',
|
|
304
|
-
tool_id: ctx.manualUploadToolId,
|
|
305
|
-
data_source_id: dataSourceId,
|
|
306
|
-
tool_call: { tool_call_type: 'manual_upload' },
|
|
307
|
-
interoperability_contract_ids: [interopContractId],
|
|
308
|
-
})
|
|
309
|
-
dacId = dacResp.data.id!
|
|
310
|
-
ctx.dacSlug = dacResp.data.slug!
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
## 008 — ingest two Stripe customer rows
|
|
314
|
-
|
|
315
|
-
Two rows, ingested as inline JSON through the manual-upload DAC.
|
|
316
|
-
Both carry a phone number; they differ in `tax_id`. The first row
|
|
317
|
-
is KYC-complete — it has a verified tax id — so the filter
|
|
318
|
-
(`customer.phone and customer.tax_id`) will pass it. The second
|
|
319
|
-
omits `tax_id` entirely; even though it has a phone, the filter
|
|
320
|
-
must reject it. Each ingest call gets its own batch id; both are
|
|
321
|
-
pinned for the run scope in §010.
|
|
322
|
-
|
|
323
|
-
```typescript
|
|
324
|
-
const phone4 = String(Date.now()).slice(-4)
|
|
325
|
-
|
|
326
|
-
const verifiedRow = {
|
|
327
|
-
customer_number: `CUST-KYC-${runSuffix}`,
|
|
328
|
-
customer_type: 'individual',
|
|
329
|
-
status: 'contracted',
|
|
330
|
-
name: 'Priya Anand',
|
|
331
|
-
email: `priya-${runSuffix}@example.com`,
|
|
332
|
-
phone: `+1202555${phone4}`,
|
|
333
|
-
tax_id: '123-45-6789',
|
|
334
|
-
currency: 'USD',
|
|
335
|
-
delinquent: 'true',
|
|
336
|
-
}
|
|
337
|
-
const unverifiedRow = {
|
|
338
|
-
customer_number: `CUST-NOKYC-${runSuffix}`,
|
|
339
|
-
customer_type: 'individual',
|
|
340
|
-
status: 'contracted',
|
|
341
|
-
name: 'Daniel Foster',
|
|
342
|
-
email: `daniel-${runSuffix}@example.com`,
|
|
343
|
-
phone: `+1203555${phone4}`,
|
|
344
|
-
currency: 'USD',
|
|
345
|
-
delinquent: 'true',
|
|
346
|
-
// tax_id intentionally absent — the workflow filter must reject this row
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const [verifiedIngest, unverifiedIngest] = await Promise.all([
|
|
350
|
-
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: verifiedRow }),
|
|
351
|
-
api.dataActivationClients.ingest(tenantSlug, datalakeSlug, ctx.dacSlug, { data: unverifiedRow }),
|
|
352
|
-
])
|
|
353
|
-
ctx.batchVerified = verifiedIngest.data.batch_id!
|
|
354
|
-
ctx.batchUnverified = unverifiedIngest.data.batch_id!
|
|
355
|
-
```
|
|
356
|
-
|
|
357
|
-
## 009 — wait for both ingest batches to reach steady-state
|
|
358
|
-
|
|
359
|
-
Ingestion is async — the DAC enqueues per-row jobs that the
|
|
360
|
-
`BatchMergeWorker` drains into the regulated customer table. Poll
|
|
361
|
-
the DAC's activation logs until both batches show a row with
|
|
362
|
-
`rows_ingested >= 1` and a non-empty `output_files` array (the
|
|
363
|
-
merged Parquet landed in object storage). Only then is it safe to
|
|
364
|
-
run the workflow against these rows.
|
|
365
|
-
|
|
366
|
-
```typescript
|
|
367
|
-
const targetBatches = new Set([ctx.batchVerified, ctx.batchUnverified])
|
|
368
|
-
const deadline = Date.now() + 90_000
|
|
369
|
-
let greenCount = 0
|
|
370
|
-
while (Date.now() < deadline) {
|
|
371
|
-
const { data } = await api.dataActivationClients.logs.list(tenantSlug, datalakeSlug, ctx.dacSlug)
|
|
372
|
-
const green = new Set<string>()
|
|
373
|
-
for (const row of (data.data ?? []) as Array<Record<string, unknown>>) {
|
|
374
|
-
const b = row.batch_id
|
|
375
|
-
if (typeof b !== 'string' || !targetBatches.has(b)) continue
|
|
376
|
-
if (typeof row.rows_ingested !== 'number' || row.rows_ingested < 1) continue
|
|
377
|
-
const files = row.output_files
|
|
378
|
-
if (!Array.isArray(files) || files.length === 0) continue
|
|
379
|
-
green.add(b)
|
|
380
|
-
}
|
|
381
|
-
greenCount = green.size
|
|
382
|
-
if (greenCount === targetBatches.size) break
|
|
383
|
-
await new Promise((r) => setTimeout(r, 1_000))
|
|
384
|
-
}
|
|
385
|
-
if (greenCount !== targetBatches.size) {
|
|
386
|
-
throw new Error(`only ${greenCount}/2 customer batches reached steady-state within 90s`)
|
|
387
|
-
}
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
## 010 — run the workflow against the two batches
|
|
391
|
-
|
|
392
|
-
`workflows.run` with `manual_override: false` evaluates the
|
|
393
|
-
filter, so the `customer.phone and customer.tax_id` filter
|
|
394
|
-
genuinely routes each row. The SQL where-clause scopes the run to
|
|
395
|
-
exactly the two batches §008 ingested (`ra` is the
|
|
396
|
-
regulated-customer alias the run-query exposes).
|
|
397
|
-
|
|
398
|
-
The SMS action's `trigger_template: 'now'` dispatches the action
|
|
399
|
-
immediately rather than deferring it, so once the run fires it
|
|
400
|
-
reaches a terminal status on its own — poll `batchLogs.refresh`
|
|
401
|
-
until it leaves `:pending`. The run itself is still scheduled:
|
|
402
|
-
`workflows.run` records it and returns, which is why the setup
|
|
403
|
-
file's `ctx.waitForFiredRun` sits between the call and the log id. A `:partial` status is expected and fine here:
|
|
404
|
-
one row passed and one was filtered.
|
|
405
|
-
|
|
406
|
-
```typescript
|
|
407
|
-
const runResp = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
408
|
-
sql_where_clause: `ra.batch_id IN ('${ctx.batchVerified}', '${ctx.batchUnverified}')`,
|
|
409
|
-
mode: 'live',
|
|
410
|
-
manual_override: false,
|
|
411
|
-
})
|
|
412
|
-
// run-workflow only SCHEDULES the run. The log id and batch id are
|
|
413
|
-
// written when it fires, so read them back via workflowRuns.get.
|
|
414
|
-
const fired = await ctx.waitForFiredRun(datalakeSlug, runResp.data.workflow_run_id)
|
|
415
|
-
ctx.runLogId = fired.workflowRunLogId
|
|
416
|
-
ctx.runBatchId = fired.batchId!
|
|
417
|
-
|
|
418
|
-
const deadline = Date.now() + 120_000
|
|
419
|
-
let status: string | null = null
|
|
420
|
-
while (Date.now() < deadline) {
|
|
421
|
-
const { data: log } = await api.workflows.batchLogs.refresh(tenantSlug, datalakeSlug, ctx.workflowSlug, ctx.runLogId)
|
|
422
|
-
status = log.status ?? null
|
|
423
|
-
if (status && status !== 'pending') break
|
|
424
|
-
await new Promise((r) => setTimeout(r, 2_000))
|
|
425
|
-
}
|
|
426
|
-
if (status === 'failed') throw new Error('workflow run reached :failed')
|
|
427
|
-
if (!status || status === 'pending') {
|
|
428
|
-
throw new Error('workflow run did not leave :pending within 120s')
|
|
429
|
-
}
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
## 011 — verify the filter routed: verified passes, unverified is filtered
|
|
433
|
-
|
|
434
|
-
Each row produced a Workflow Execution Log. The KYC-complete
|
|
435
|
-
customer (phone + tax id) passes the filter, so its WEL is
|
|
436
|
-
`:executing` or `:completed`. The customer missing the tax id
|
|
437
|
-
fails the filter, so its WEL is `:filtered`. A run where both
|
|
438
|
-
passed — or both were filtered — would mean the filter is not
|
|
439
|
-
actually evaluating both `customer.phone` and `customer.tax_id`.
|
|
440
|
-
|
|
441
|
-
```typescript
|
|
442
|
-
const { data: wfLogs } = await api.workflows.workflowLogs.list(tenantSlug, datalakeSlug, ctx.workflowSlug)
|
|
443
|
-
const ourWels = (wfLogs.data ?? []).filter(
|
|
444
|
-
(w) => (w as { batch_id?: string }).batch_id === ctx.runBatchId,
|
|
445
|
-
)
|
|
446
|
-
if (ourWels.length !== 2) {
|
|
447
|
-
throw new Error(`expected 2 WELs for the run, got ${ourWels.length}`)
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const byStatus: Record<string, number> = {}
|
|
451
|
-
for (const w of ourWels) {
|
|
452
|
-
const st = (w as { status?: string }).status ?? 'unknown'
|
|
453
|
-
byStatus[st] = (byStatus[st] ?? 0) + 1
|
|
454
|
-
}
|
|
455
|
-
const passCount = (byStatus.executing ?? 0) + (byStatus.completed ?? 0)
|
|
456
|
-
if (passCount !== 1) {
|
|
457
|
-
throw new Error(`expected 1 pass-branch WEL, got ${passCount} — distribution ${JSON.stringify(byStatus)}`)
|
|
458
|
-
}
|
|
459
|
-
if ((byStatus.filtered ?? 0) !== 1) {
|
|
460
|
-
throw new Error(
|
|
461
|
-
`expected 1 :filtered WEL (the tax-id-missing customer) — distribution ${JSON.stringify(byStatus)}`,
|
|
462
|
-
)
|
|
463
|
-
}
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
## 012 — read the rendered dunning SMS back from the message dataset
|
|
467
|
-
|
|
468
|
-
A `trigger_template: 'now'` action that passed the filter fires
|
|
469
|
-
immediately and persists a row in the regulated `message`
|
|
470
|
-
dataset, carrying the fully-rendered SMS body. Search that
|
|
471
|
-
dataset scoped to this workflow, poll until the row appears, and
|
|
472
|
-
extract the `/t/<token>` shortlink the platform baked into the
|
|
473
|
-
body when it minted the connected-app page token.
|
|
474
|
-
|
|
475
|
-
```typescript
|
|
476
|
-
const { data: userSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
|
|
477
|
-
search_query: `rm.workflow_id = '${workflowId}'`,
|
|
478
|
-
})
|
|
479
|
-
if (userSearch.status !== 'completed') {
|
|
480
|
-
throw new Error(`message user-search status=${userSearch.status} error=${userSearch.error_message ?? '(none)'}`)
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
const deadline = Date.now() + 45_000
|
|
484
|
-
let messages: Array<Record<string, unknown>> = []
|
|
485
|
-
while (Date.now() < deadline && messages.length === 0) {
|
|
486
|
-
const { data } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
|
|
487
|
-
userSearchId: userSearch.id!,
|
|
488
|
-
dataAccessMode: 'regulated',
|
|
489
|
-
})
|
|
490
|
-
messages = (data.data ?? []) as Array<Record<string, unknown>>
|
|
491
|
-
if (messages.length === 0) await new Promise((r) => setTimeout(r, 1_000))
|
|
492
|
-
}
|
|
493
|
-
if (messages.length === 0) {
|
|
494
|
-
throw new Error('no dunning SMS message persisted for the workflow within 45s')
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
const withLink = messages
|
|
498
|
-
.map((m) => String(m.body ?? ''))
|
|
499
|
-
.find((body) => body.includes('/t/') && body.includes('needs your attention'))
|
|
500
|
-
if (!withLink) {
|
|
501
|
-
throw new Error(`no rendered dunning body with a /t/ link — bodies: ${JSON.stringify(messages.map((m) => m.body))}`)
|
|
502
|
-
}
|
|
503
|
-
const tokenMatch = withLink.match(/\/t\/([A-Za-z0-9_-]+)/)
|
|
504
|
-
if (!tokenMatch) {
|
|
505
|
-
throw new Error(`no /t/<token> in rendered body: ${withLink}`)
|
|
506
|
-
}
|
|
507
|
-
ctx.dunningShortPath = tokenMatch[1]!
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
## 013 — resolve the connected-app deep-link and post tracking
|
|
511
|
-
|
|
512
|
-
The `/t/<token>` shortlink the SMS carries resolves via
|
|
513
|
-
`connectedApps.resolvePage` — the `route_path` must match the
|
|
514
|
-
action's `connected_app_route`. Posting `opened_at` +
|
|
515
|
-
`form_submitted_at` via `updateMessageTracking` then mirrors what
|
|
516
|
-
the connected-app frontend does when the customer opens the
|
|
517
|
-
payment portal, closing the SMS → reply loop end-to-end.
|
|
518
|
-
|
|
519
|
-
```typescript
|
|
520
|
-
const { data: resolved } = await api.connectedApps.resolvePage(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
|
|
521
|
-
short_path: ctx.dunningShortPath,
|
|
522
|
-
user_agent: 'cookbook-doctest/dunning-sms',
|
|
523
|
-
})
|
|
524
|
-
if (resolved.route_path !== '/portal/pay') {
|
|
525
|
-
throw new Error(`resolvePage route_path mismatch: ${resolved.route_path}`)
|
|
526
|
-
}
|
|
527
|
-
if (!(resolved.message?.body ?? '').includes('needs your attention')) {
|
|
528
|
-
throw new Error('resolved page message body missing the dunning copy')
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const now = new Date().toISOString()
|
|
532
|
-
const { data: tracked } = await api.connectedApps.updateMessageTracking(tenantSlug, datalakeSlug, ctx.connectedAppSlug, {
|
|
533
|
-
short_path: ctx.dunningShortPath,
|
|
534
|
-
opened_at: now,
|
|
535
|
-
form_submitted_at: now,
|
|
536
|
-
})
|
|
537
|
-
if (!tracked.message?.opened_at || !tracked.message?.form_submitted_at) {
|
|
538
|
-
throw new Error('message tracking did not persist opened_at + form_submitted_at')
|
|
539
|
-
}
|
|
540
|
-
```
|
|
541
|
-
|
|
542
|
-
## 014 — write the integration test
|
|
543
|
-
|
|
544
|
-
End the build with a test you keep: re-read the workflow and prove the
|
|
545
|
-
pipeline still executes — without a side effect. `mode: 'dry_run'` with a
|
|
546
|
-
never-matching selection runs the FULL pipeline (selection → filter →
|
|
547
|
-
decision) and intercepts only the final action call, so no message
|
|
548
|
-
leaves, yet the acknowledgement proves the workflow is runnable. This
|
|
549
|
-
block runs live under `make validate-cookbook`.
|
|
550
|
-
|
|
551
|
-
```typescript
|
|
552
|
-
// Re-GET — the workflow must still be live, or nothing will run.
|
|
553
|
-
const { data: wfRow } = await api.workflows.get(tenantSlug, datalakeSlug, workflowId)
|
|
554
|
-
if (wfRow.status !== 'live') {
|
|
555
|
-
throw new Error(`workflow regressed from live: ${wfRow.status}`)
|
|
556
|
-
}
|
|
557
|
-
// Behavioural probe — a dry run against a selection no row can match:
|
|
558
|
-
// the pipeline executes end-to-end, the final action call is
|
|
559
|
-
// intercepted, and the acknowledgement carries the scheduled run id.
|
|
560
|
-
const { data: probeRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.workflowSlug, {
|
|
561
|
-
sql_where_clause: "ra.batch_id = 'test-never-matching-batch'",
|
|
562
|
-
mode: 'dry_run',
|
|
563
|
-
manual_override: false,
|
|
564
|
-
})
|
|
565
|
-
// The run-log id does not exist until the run fires — wait, do not read a null.
|
|
566
|
-
const probeFired = await ctx.waitForFiredRun(datalakeSlug, probeRun.workflow_run_id)
|
|
567
|
-
if (probeFired.workflowRunLogId.length === 0) {
|
|
568
|
-
throw new Error('dry-run probe never produced a workflow_run_log_id')
|
|
569
|
-
}
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
If the probe fails in production, escalate with the run response as
|
|
573
|
-
evidence — don't flip the workflow's status or rewrite its configs to
|
|
574
|
-
chase the error.
|
|
575
|
-
|
|
576
|
-
# Branches
|
|
577
|
-
|
|
578
|
-
- **The unverified customer is filtered, not failed** — §011
|
|
579
|
-
asserts the tax-id-missing customer's WEL is `:filtered`, a
|
|
580
|
-
distinct terminal status from `:failed`. A filtered row is a
|
|
581
|
-
*correct* outcome: the workflow looked at it, the
|
|
582
|
-
`customer.phone and customer.tax_id` filter rendered empty
|
|
583
|
-
because one conjunct was falsy, and the platform recorded the
|
|
584
|
-
row as intentionally skipped. No SMS runs for a filtered row —
|
|
585
|
-
exactly the business rule (never dun a customer whose KYC is
|
|
586
|
-
incomplete).
|
|
587
|
-
- **Both conjuncts are load-bearing** — the filter is an `and` of
|
|
588
|
-
two preconditions. The §008 unverified row has a phone, so it
|
|
589
|
-
is the *tax-id* conjunct that fails it. A row missing the phone
|
|
590
|
-
instead would fail the same filter on the other conjunct. The
|
|
591
|
-
anchor vitest's §8 proves the opposite extreme: a filter gated
|
|
592
|
-
on a sentinel no customer can satisfy filters *every* row.
|
|
593
|
-
- **The thirty-day recency guard** — §003's `context_datasets`
|
|
594
|
-
entry queries the `message` dataset for a prior dunning SMS to
|
|
595
|
-
the same customer in the last 30 days. On this fresh tenant it
|
|
596
|
-
returns empty, so the action runs. On a tenant with history, a
|
|
597
|
-
recent prior dunning message would make the action skip — the
|
|
598
|
-
platform's built-in guard against dunning the same customer
|
|
599
|
-
repeatedly within one billing cycle.
|
|
600
|
-
|
|
601
|
-
# Rollback
|
|
602
|
-
|
|
603
|
-
The cookbook doctest harness does not currently tear down created
|
|
604
|
-
resources. The `_setup/subscription.md` setup file's
|
|
605
|
-
runSuffix-scoped tenant / datalake / user names mean each run is
|
|
606
|
-
naturally isolated; the seeded local DB is cheap to reset
|
|
607
|
-
(`mix ecto.reset` on the platform repo).
|
|
608
|
-
|
|
609
|
-
# Outcome
|
|
610
|
-
|
|
611
|
-
After this cookbook's thirteen steps run green:
|
|
612
|
-
|
|
613
|
-
- An subscription tenant exists with an
|
|
614
|
-
subscription-domain datalake
|
|
615
|
-
- An SMS tool, a Dunning payment-portal connected app, and a
|
|
616
|
-
Dunning SMS standard workflow (status `live`, `customer`
|
|
617
|
-
dataset) are registered
|
|
618
|
-
- A Stripe data source, a Manual Upload tool, a Stripe Customer
|
|
619
|
-
interop contract, and a manual-upload DAC form a working
|
|
620
|
-
ingestion chain
|
|
621
|
-
- Two customers have been ingested through that chain — one
|
|
622
|
-
KYC-complete, one missing a tax id
|
|
623
|
-
- Running the workflow routed them correctly: the verified
|
|
624
|
-
customer passed the phone + tax-id filter and dispatched an
|
|
625
|
-
SMS; the unverified customer was `:filtered`
|
|
626
|
-
- The rendered dunning SMS is readable from the `message`
|
|
627
|
-
dataset, carrying the "needs your attention" copy with the
|
|
628
|
-
overdue account number and a `/t/` deep-link
|
|
629
|
-
- The connected-app deep-link the SMS carried resolves, and
|
|
630
|
-
message tracking records the open + form-submit timestamps
|
|
631
|
-
|
|
632
|
-
The business outcome — a payment-reminder SMS sent to a reachable
|
|
633
|
-
and KYC-complete delinquent customer, with a working pay-invoice
|
|
634
|
-
link — is demonstrated end-to-end, not merely provisioned.
|
|
635
|
-
|
|
636
|
-
# See also
|
|
637
|
-
|
|
638
|
-
- `_setup/subscription.md` — the inlined bootstrap that
|
|
639
|
-
provisions the tenant + datalake this cookbook starts from
|
|
640
|
-
- `welcome-sms-for-customers.md` — the sibling AR cookbook; same
|
|
641
|
-
customer-dataset shape, a simpler phone-only filter
|
|
642
|
-
- `.agent/tools.md` — SMS (`tool_body_type: sns`) and
|
|
643
|
-
manual-upload tool body shapes; intent classification
|
|
644
|
-
- `.agent/connected_apps.md` — connected-app registration + the
|
|
645
|
-
resolve-page / message-tracking reply loop
|
|
646
|
-
- `.agent/interoperability_contracts.md` — custom contract shape,
|
|
647
|
-
`template_config` vs `mdm_input_config`
|
|
648
|
-
- `.agent/data_activation_clients.md` — data source → tool →
|
|
649
|
-
interop contract → DAC ingestion chain
|
|
650
|
-
- `.agent/workflows.md` — standard workflow primitive (filter +
|
|
651
|
-
decision + actions), `context_datasets`, `workflows.run`
|
|
652
|
-
- `.agent/cookbook/_fixtures/subscription/` — the vendored
|
|
653
|
-
Stripe customer / MDM Liquid templates §006 loads
|
|
654
|
-
- `integration-tests/tests/subscription/dunning-sms-workflow.test.ts` —
|
|
655
|
-
the anchor green test (§1–§8) these snippets are lifted from
|
|
656
|
-
- `integration-tests/tests/subscription/interoperability-contracts.test.ts`,
|
|
657
|
-
`run-dac-single.test.ts`, `create-dac.test.ts`, `tools.test.ts`,
|
|
658
|
-
`data-sources.test.ts` — the per-resource create + ingest
|
|
659
|
-
snippets §004–§009 are lifted from
|