@ar-agents/mercadopago 0.15.2 → 0.16.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.
@@ -1,5 +1,5 @@
1
1
  <!--
2
- README skeleton Vercel-official quality.
2
+ README skeleton: Vercel-official quality.
3
3
  Drop this in as packages/mercadopago/README.md after a final once-over.
4
4
  The structure follows the patterns Vercel themselves use on
5
5
  https://github.com/vercel/ai-sdk and https://github.com/vercel/next.js
@@ -7,7 +7,7 @@
7
7
 
8
8
  Hard rules:
9
9
  - Lead with what it is and a 3-line install + first call.
10
- - Real numbers (latency, bundle size) only no "fast", "blazing", "robust".
10
+ - Real numbers (latency, bundle size) only: no "fast", "blazing", "robust".
11
11
  - Code blocks runnable as-pasted (no `// ...` placeholders that hide work).
12
12
  - One-screen scrolling for the top: header → install → first call → core API.
13
13
  - Everything below is reference, not pitch.
@@ -23,7 +23,7 @@
23
23
 
24
24
  Mercado Pago Agent Toolkit. Built on Vercel. 89 typed tools across the agent-relevant Mercado Pago API surface (Subscriptions, Payments, Checkout Pro, Marketplace OAuth, Order Management, Customers, Cards, Cuotas, QR, 3DS, Point devices, Stores+POS, Account/Balance/Settlements, Webhooks, Disputes, Lookups, Bank Accounts) for the [Vercel AI SDK](https://ai-sdk.dev/) 6 `Experimental_Agent`. Edge-Runtime-safe.
25
25
 
26
- > **Reading this as an agent?** Skip to [AGENTS.md](./AGENTS.md) decision tree, result schemas to memorize, error patterns, latency table.
26
+ > **Reading this as an agent?** Skip to [AGENTS.md](./AGENTS.md): decision tree, result schemas to memorize, error patterns, latency table.
27
27
 
28
28
  ## Quick start
29
29
 
@@ -57,16 +57,16 @@ That's it. The agent picks `create_subscription`, returns an `init_point_url` yo
57
57
 
58
58
  | | |
59
59
  | --- | --- |
60
- | **Tools** | 30 Subscriptions, Payments, Refunds, Checkout Pro, Cuotas, QR in-store, Saved cards, Marketplace OAuth, Order Management, Point devices, 3DS, Webhooks. [Full list](./AGENTS.md#tool-selection). |
60
+ | **Tools** | 30: Subscriptions, Payments, Refunds, Checkout Pro, Cuotas, QR in-store, Saved cards, Marketplace OAuth, Order Management, Point devices, 3DS, Webhooks. [Full list](./AGENTS.md#tool-selection). |
61
61
  | **Bundle size** | 41 KB ESM brotli'd ([bundlephobia](https://bundlephobia.com/package/@ar-agents/mercadopago)). Tree-shakable subpath exports for `/vercel-kv` + `/otel`. |
62
- | **Runtime** | Vercel Edge, Node 18+, Cloudflare Workers, Deno Web Crypto under the hood. |
62
+ | **Runtime** | Vercel Edge, Node 18+, Cloudflare Workers, Deno: Web Crypto under the hood. |
63
63
  | **Tests** | 290 unit + property + failure-injection + benchmark. `pnpm test`, `pnpm bench`. |
64
64
  | **TypeScript** | Strict mode, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`. publint + arethetypeswrong all 🟢. |
65
65
  | **AR-specific knowledge** | Cuotas with regulatory text (RG 5286/2023), AR issuer promo catalog, Subscription replay-protection, MLA-verified, ARS default. |
66
66
 
67
67
  ## Server-only
68
68
 
69
- This package **MUST** run on the server. The constructor throws if instantiated in a browser context the access token would be exposed in the JavaScript bundle. Use Server Components, Route Handlers, or Server Actions.
69
+ This package **MUST** run on the server. The constructor throws if instantiated in a browser context: the access token would be exposed in the JavaScript bundle. Use Server Components, Route Handlers, or Server Actions.
70
70
 
71
71
  ```ts
72
72
  // ❌ Never. Throws at runtime AND would leak the token if it didn't.
@@ -89,14 +89,14 @@ Server-side MP API client. Edge-Runtime safe.
89
89
 
90
90
  | Option | Default | Description |
91
91
  | --- | --- | --- |
92
- | `accessToken` (required) | | TEST- prefix for sandbox, APP_USR- for production. |
92
+ | `accessToken` (required) | no | TEST- prefix for sandbox, APP_USR- for production. |
93
93
  | `baseUrl` | `https://api.mercadopago.com` | Override for tests / regional hosts. |
94
94
  | `fetch` | `globalThis.fetch` | Custom fetch (e.g., MSW for tests). |
95
95
  | `requestTimeoutMs` | `30_000` | Per-request timeout. |
96
96
  | `maxRetries` | `1` | 5xx + network retries. 4xx never retried. |
97
- | `circuitBreaker` | | `new CircuitBreaker({ ... })` to fail fast on cascading failures. |
98
- | `traceContext` | | OpenTelemetry context propagator (W3C trace headers). |
99
- | `onCall` | | Observability hook fired after every request. |
97
+ | `circuitBreaker` | no | `new CircuitBreaker({ ... })` to fail fast on cascading failures. |
98
+ | `traceContext` | no | OpenTelemetry context propagator (W3C trace headers). |
99
+ | `onCall` | no | Observability hook fired after every request. |
100
100
 
101
101
  ### `mercadoPagoTools(client, options)`
102
102
 
@@ -104,7 +104,7 @@ Returns the agent tool set wired to the given client.
104
104
 
105
105
  | Option | Required | Description |
106
106
  | --- | --- | --- |
107
- | `state` | yes | `SubscriptionStateAdapter` `InMemoryStateAdapter`, `VercelKVSubscriptionStateAdapter`, or your own. |
107
+ | `state` | yes | `SubscriptionStateAdapter`: `InMemoryStateAdapter`, `VercelKVSubscriptionStateAdapter`, or your own. |
108
108
  | `backUrl` | yes | HTTPS URL where MP redirects buyers after first payment. localhost rejected. |
109
109
  | `notificationUrl` | no | Webhook URL for new payments / status changes. |
110
110
  | `oauth` | no | `{ clientId, clientSecret, redirectUri, tokenStore }` for marketplace OAuth flows. |
@@ -123,7 +123,7 @@ Returns the agent tool set wired to the given client.
123
123
 
124
124
  ### Idempotency by default
125
125
 
126
- Every `POST` request gets an auto-generated UUID idempotency key your app survives network blips without double-charging. For LLM-driven retries, `create_payment`, `create_subscription`, `create_payment_preference`, and `refund_payment` use a **deterministic** key derived from the inputs, so a tool retried with the same inputs returns the existing resource instead of creating a duplicate.
126
+ Every `POST` request gets an auto-generated UUID idempotency key: your app survives network blips without double-charging. For LLM-driven retries, `create_payment`, `create_subscription`, `create_payment_preference`, and `refund_payment` use a **deterministic** key derived from the inputs, so a tool retried with the same inputs returns the existing resource instead of creating a duplicate.
127
127
 
128
128
  ### Webhook verification
129
129
 
@@ -164,22 +164,22 @@ const limiter = new VercelKVRateLimiter({
164
164
 
165
165
  ### Cookbook
166
166
 
167
- 9 recipes in [`./cookbook`](./cookbook/) Checkout Pro, SaaS subscription, webhook handler, marketplace split, QR in-store, 3DS challenge, manual capture, recovery patterns, full OpenTelemetry wiring.
167
+ 9 recipes in [`./cookbook`](./cookbook/): Checkout Pro, SaaS subscription, webhook handler, marketplace split, QR in-store, 3DS challenge, manual capture, recovery patterns, full OpenTelemetry wiring.
168
168
 
169
169
  ## Comparison
170
170
 
171
171
  | | `@ar-agents/mercadopago` | `mercadopago` (official SDK) | Hand-rolled |
172
172
  | --- | --- | --- | --- |
173
- | Tools as Vercel AI SDK 6 schemas | ✓ | | build it |
174
- | AR-specific (cuotas, AR issuer promos, AR phone, MLA-verified) | ✓ | | weeks |
175
- | `AGENTS.md` per package (LLM-readable) | ✓ | | |
176
- | Idempotency-by-default for state mutations | ✓ | | build it |
173
+ | Tools as Vercel AI SDK 6 schemas | ✓ | no | build it |
174
+ | AR-specific (cuotas, AR issuer promos, AR phone, MLA-verified) | ✓ | no | weeks |
175
+ | `AGENTS.md` per package (LLM-readable) | ✓ | no |: |
176
+ | Idempotency-by-default for state mutations | ✓ | no | build it |
177
177
  | Webhook signature verify + 5-min replay window | ✓ | client only | build it |
178
178
  | Edge Runtime support | ✓ | Node-only | build it |
179
- | Vercel KV adapters via subpath | ✓ | | |
180
- | OpenTelemetry instrumentation + recipe | ✓ | | build it |
181
- | Circuit breaker + deadline propagation | ✓ | | build it |
182
- | Tool middleware (compose audit/rate/metrics) | ✓ | | |
179
+ | Vercel KV adapters via subpath | ✓ | no |: |
180
+ | OpenTelemetry instrumentation + recipe | ✓ | no | build it |
181
+ | Circuit breaker + deadline propagation | ✓ | no | build it |
182
+ | Tool middleware (compose audit/rate/metrics) | ✓ | no |: |
183
183
  | Time to first cobro | 30 min | 1+ week | 6-8 weeks |
184
184
 
185
185
  See [`MIGRATION.md`](./MIGRATION.md) for a side-by-side `mercadopago` → `@ar-agents/mercadopago` migration guide.
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Recipe 10 — Cross-package billing assistant.
3
+ *
4
+ * The killer demo of the @ar-agents/* toolkit's composability. ONE agent loop,
5
+ * five packages working together to do what would normally be 200 lines of
6
+ * orchestration code:
7
+ *
8
+ * 1. @ar-agents/identity — validate the buyer's CUIT, look up
9
+ * AFIP padron (monotributo + IVA condition)
10
+ * 2. @ar-agents/identity-attest — gate large charges behind WhatsApp OTP
11
+ * 3. @ar-agents/mercadopago — run the actual subscription / payment
12
+ * 4. @ar-agents/facturacion — emit factura electrónica WSFE on success
13
+ * 5. @ar-agents/whatsapp — send confirmation + invoice link
14
+ *
15
+ * Real production pattern: invoice an Argentine SMB customer, fully driven
16
+ * by an LLM agent reading natural-language business prompts.
17
+ *
18
+ * Run with `pnpm tsx cookbook/10-cross-package-billing.ts` after wiring env:
19
+ * MP_ACCESS_TOKEN
20
+ * AFIP_CERT_PEM, AFIP_KEY_PEM, AFIP_CUIT
21
+ * WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID
22
+ * ATTESTATION_HMAC_SECRET
23
+ */
24
+
25
+ import { Experimental_Agent as Agent, stepCountIs, type ToolSet } from "ai";
26
+
27
+ // 1. Mercado Pago — always present, the headline package.
28
+ import {
29
+ MercadoPagoClient,
30
+ mercadoPagoTools,
31
+ InMemoryStateAdapter,
32
+ } from "@ar-agents/mercadopago";
33
+
34
+ // 2-5. Sidecar packages. Imported up-front because every cross-package agent
35
+ // will eventually need them; tree-shaking handles unused ones.
36
+ import {
37
+ identityTools,
38
+ WsaaWscdcAfipPadronAdapter,
39
+ UnconfiguredAfipPadronAdapter,
40
+ type AfipPadronAdapter,
41
+ } from "@ar-agents/identity";
42
+ import {
43
+ AttestationClient,
44
+ identityAttestTools,
45
+ InMemoryAttestationStore,
46
+ } from "@ar-agents/identity-attest";
47
+ import {
48
+ WsfeClient,
49
+ facturacionTools,
50
+ } from "@ar-agents/facturacion";
51
+ import {
52
+ WhatsAppClient,
53
+ whatsappTools,
54
+ } from "@ar-agents/whatsapp";
55
+
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+ // Build the cross-package tool surface
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ export async function buildBillingAgent() {
61
+ const tools: ToolSet = {};
62
+
63
+ // ── Mercado Pago ──────────────────────────────────────────────────────────
64
+ const mp = new MercadoPagoClient({
65
+ accessToken: process.env.MP_ACCESS_TOKEN!,
66
+ });
67
+ Object.assign(
68
+ tools,
69
+ mercadoPagoTools(mp, {
70
+ state: new InMemoryStateAdapter(),
71
+ backUrl: process.env.NEXT_PUBLIC_BACK_URL ?? "https://example.com/done",
72
+ // HITL on irreversible ops. In production: push approval request to a
73
+ // dashboard / Slack / email and block on user UI. For the demo: auto-OK.
74
+ requireConfirmation: async (toolName, params) => {
75
+ console.log(`[HITL] ${toolName} called with`, params);
76
+ return true;
77
+ },
78
+ }),
79
+ );
80
+
81
+ // ── Identity (CUIT + AFIP/ARCA padron) ────────────────────────────────────
82
+ // Wire the real WSAA adapter only when the cert is present; otherwise the
83
+ // unconfigured adapter is registered so `validate_cuit` works (algorithm
84
+ // only) but `lookup_padron` returns "not configured" cleanly.
85
+ const afipAdapter: AfipPadronAdapter =
86
+ process.env.AFIP_CERT_PEM && process.env.AFIP_KEY_PEM
87
+ ? new WsaaWscdcAfipPadronAdapter({
88
+ certPem: process.env.AFIP_CERT_PEM,
89
+ keyPem: process.env.AFIP_KEY_PEM,
90
+ cuitRepresentado: process.env.AFIP_CUIT!,
91
+ env: "prod",
92
+ })
93
+ : new UnconfiguredAfipPadronAdapter();
94
+ Object.assign(tools, identityTools({ afip: afipAdapter }));
95
+
96
+ // ── Identity-attest (WhatsApp OTP gate for >$50k) ─────────────────────────
97
+ if (process.env.ATTESTATION_HMAC_SECRET) {
98
+ const attestClient = new AttestationClient({
99
+ hmacSecret: process.env.ATTESTATION_HMAC_SECRET,
100
+ store: new InMemoryAttestationStore(),
101
+ });
102
+ Object.assign(tools, identityAttestTools(attestClient));
103
+ }
104
+
105
+ // ── Facturación (factura electrónica WSFE) ────────────────────────────────
106
+ if (process.env.AFIP_CERT_PEM && process.env.AFIP_KEY_PEM) {
107
+ const wsfe = new WsfeClient({
108
+ certPem: process.env.AFIP_CERT_PEM,
109
+ keyPem: process.env.AFIP_KEY_PEM,
110
+ cuit: Number(process.env.AFIP_CUIT!),
111
+ env: "prod",
112
+ });
113
+ Object.assign(tools, facturacionTools({ wsfe }));
114
+ }
115
+
116
+ // ── WhatsApp (confirmation + invoice link) ────────────────────────────────
117
+ if (process.env.WHATSAPP_ACCESS_TOKEN && process.env.WHATSAPP_PHONE_NUMBER_ID) {
118
+ const wa = new WhatsAppClient({
119
+ accessToken: process.env.WHATSAPP_ACCESS_TOKEN,
120
+ phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID,
121
+ });
122
+ Object.assign(tools, whatsappTools({ client: wa }));
123
+ }
124
+
125
+ return new Agent({
126
+ model: "anthropic/claude-sonnet-4-6",
127
+ instructions:
128
+ "Sos un asistente de billing para SaaS argentinas. Antes de cobrar, " +
129
+ "validás el CUIT con `validate_cuit` y consultás el padrón AFIP con " +
130
+ "`lookup_padron` para conocer la condición IVA del receptor. Para " +
131
+ "cargos sobre $50.000 ARS, gatillás verificación WhatsApp OTP via " +
132
+ "`request_attestation`. Después del cobro emitís factura electrónica " +
133
+ "con `crear_factura` (B si es Consumidor Final, A si es Responsable " +
134
+ "Inscripto, C si tu emisor es monotributo). Mandás link del " +
135
+ "comprobante por WhatsApp con `send_text`. Respondé en castellano " +
136
+ "rioplatense, breve, sin emojis.",
137
+ tools,
138
+ stopWhen: stepCountIs(15), // higher than usual — multi-package flows take steps
139
+ });
140
+ }
141
+
142
+ // ─────────────────────────────────────────────────────────────────────────────
143
+ // Example invocation
144
+ // ─────────────────────────────────────────────────────────────────────────────
145
+
146
+ async function main() {
147
+ const agent = await buildBillingAgent();
148
+
149
+ // What the agent should do behind this prompt:
150
+ // 1. validate_cuit("20-12345678-9") → ok
151
+ // 2. lookup_padron("20-12345678-9") → returns "Acme SRL, monotributo Cat A, Responsable Inscripto"
152
+ // 3. amount > $50k → request_attestation(method="whatsapp_otp", target="+5491155555555")
153
+ // 4. (after OTP confirmed) create_subscription({ amount: 75000, frequency: "monthly", payerEmail })
154
+ // 5. (async, after first payment webhook) crear_factura(B, monto, items)
155
+ // 6. send_text(phone, "Suscripción activa. Factura: $url")
156
+ const result = await agent.generate({
157
+ prompt:
158
+ "Cobrale $75.000 mensual a Acme SRL (CUIT 20-12345678-9, " +
159
+ "email contacto@acme.example, WhatsApp +5491155555555) por el plan Pro. " +
160
+ "Como supera los $50k, gatillá la verificación primero. Después emití " +
161
+ "factura B y mandales el link por WhatsApp.",
162
+ });
163
+
164
+ console.log(result.text);
165
+ }
166
+
167
+ if (process.argv[1]?.endsWith("10-cross-package-billing.ts")) {
168
+ main().catch((err) => {
169
+ console.error(err);
170
+ process.exit(1);
171
+ });
172
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Recipe 11 — Dunning sequence: failed-payment recovery loop.
3
+ *
4
+ * Real production pattern. A subscription's recurring charge fails. You don't
5
+ * just give up — you run a multi-step recovery sequence that maximises revenue
6
+ * recovery and minimises customer churn.
7
+ *
8
+ * # The dunning sequence
9
+ *
10
+ * Day 0 Charge fails (most commonly: insufficient funds, card expired).
11
+ * → MP retries automatically (configurable on the subscription, default 3 attempts).
12
+ * Day 0 Webhook: `subscription_authorized_payment` with status=rejected.
13
+ * → Send "Hubo un problema con tu cobro" email + WhatsApp.
14
+ * → Include the buyer's `init_point_url` so they can retry the
15
+ * card on MP's UI without you collecting card data.
16
+ * Day 3 Still no successful retry.
17
+ * → Pause the subscription via `pause_subscription`.
18
+ * → Send a softer "Tu suscripción está pausada — ¿querés que
19
+ * actualicemos la tarjeta?" message.
20
+ * Day 7 No card swap.
21
+ * → Send retention offer: "Te damos un mes gratis si volvés".
22
+ * Day 14 No response to retention.
23
+ * → Cancel the subscription. Send "Cancelamos. ¿Te podemos ayudar
24
+ * con algo?" message with feedback link.
25
+ *
26
+ * # What this recipe shows
27
+ *
28
+ * - Webhook handler reading `subscription_authorized_payment` events.
29
+ * - State machine driven by elapsed time + buyer responses.
30
+ * - Composition with @ar-agents/whatsapp for the dunning message channel.
31
+ * - HITL gating on the cancellation step (retention managers might want
32
+ * to manually approve cancellations of high-value accounts).
33
+ */
34
+
35
+ import {
36
+ MercadoPagoClient,
37
+ parseWebhookEvent,
38
+ verifyWebhookSignature,
39
+ explainPaymentStatus,
40
+ type ParsedWebhookEvent,
41
+ type SubscriptionPayment,
42
+ } from "@ar-agents/mercadopago";
43
+
44
+ const mp = new MercadoPagoClient({
45
+ accessToken: process.env.MP_ACCESS_TOKEN!,
46
+ });
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+ // State store
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+
52
+ // In production: VercelKV / Redis / Postgres. Schema:
53
+ // key: dunning:<subscriptionId>
54
+ // value: { firstFailureAt, attemptsSent, status: "active" | "paused" | "cancelled" }
55
+ type DunningState = {
56
+ subscriptionId: string;
57
+ firstFailureAt: number;
58
+ attemptsSent: number;
59
+ status: "active" | "paused" | "cancelled";
60
+ buyerEmail: string;
61
+ buyerWhatsApp?: string;
62
+ };
63
+
64
+ const dunningStore = new Map<string, DunningState>();
65
+
66
+ // ─────────────────────────────────────────────────────────────────────────────
67
+ // Webhook handler — entry point for the dunning sequence
68
+ // ─────────────────────────────────────────────────────────────────────────────
69
+
70
+ export async function POST(req: Request) {
71
+ const url = new URL(req.url);
72
+ const rawBody = await req.text();
73
+
74
+ const ok = await verifyWebhookSignature({
75
+ requestId: req.headers.get("x-request-id"),
76
+ dataId: parseWebhookEvent(JSON.parse(rawBody), url.searchParams)?.dataId ?? "",
77
+ signatureHeader: req.headers.get("x-signature"),
78
+ secret: process.env.MP_WEBHOOK_SECRET!,
79
+ });
80
+ if (!ok) return new Response("invalid signature", { status: 401 });
81
+
82
+ const event = parseWebhookEvent(JSON.parse(rawBody), url.searchParams);
83
+ if (!event) return new Response("ok", { status: 200 });
84
+
85
+ // Two relevant topics: subscription_authorized_payment (recurring charge),
86
+ // and payment.updated (in case of one-shot charge associated to a sub).
87
+ if (event.topic === "subscription_authorized_payment") {
88
+ await handleRecurringChargeWebhook(event);
89
+ }
90
+
91
+ return new Response("ok", { status: 200 });
92
+ }
93
+
94
+ /**
95
+ * The webhook payload includes a `data.id` for the SubscriptionPayment that
96
+ * fired. To find the parent preapproval we hit MP's auth payments endpoint
97
+ * directly — there's no single-record getter on the client (MP's API returns
98
+ * authorized_payments only via the search endpoint), so the recipe goes
99
+ * through the raw request helper.
100
+ */
101
+ async function fetchSubscriptionPaymentById(
102
+ authPaymentId: string,
103
+ ): Promise<SubscriptionPayment | null> {
104
+ // The toolkit doesn't expose a single-record getter for SubscriptionPayment
105
+ // because MP doesn't ship one either. The closest stable path is the
106
+ // /authorized_payments search query. In your dunning state store you'll
107
+ // already know the preapproval_id, so this lookup is rarely needed —
108
+ // included here for completeness when the webhook is the only source.
109
+ try {
110
+ const url = `https://api.mercadopago.com/authorized_payments/${authPaymentId}`;
111
+ const res = await fetch(url, {
112
+ headers: {
113
+ Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}`,
114
+ },
115
+ });
116
+ if (!res.ok) return null;
117
+ return (await res.json()) as SubscriptionPayment;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ async function handleRecurringChargeWebhook(event: ParsedWebhookEvent) {
124
+ const ap = await fetchSubscriptionPaymentById(event.dataId);
125
+ if (!ap || !ap.preapproval_id) return;
126
+
127
+ if (ap.status === "approved") {
128
+ // Reset dunning state — recurring charge recovered.
129
+ dunningStore.delete(ap.preapproval_id);
130
+ return;
131
+ }
132
+
133
+ if (ap.status !== "rejected") return; // pending — wait for next event
134
+
135
+ // Charge was rejected. Engage the dunning sequence. explainPaymentStatus
136
+ // wants a full Payment shape; the relevant fields (status + status_detail)
137
+ // come from the SubscriptionPayment, so we widen the cast.
138
+ const explained = explainPaymentStatus({
139
+ id: String(ap.id),
140
+ status: ap.status,
141
+ status_detail: ap.reason ?? "",
142
+ transaction_amount: ap.transaction_amount ?? 0,
143
+ currency_id: ap.currency_id ?? "ARS",
144
+ } as unknown as Parameters<typeof explainPaymentStatus>[0]);
145
+
146
+ const sub = await mp.getPreapproval(ap.preapproval_id);
147
+ let state = dunningStore.get(ap.preapproval_id);
148
+
149
+ if (!state) {
150
+ state = {
151
+ subscriptionId: ap.preapproval_id,
152
+ firstFailureAt: Date.now(),
153
+ attemptsSent: 0,
154
+ status: "active",
155
+ buyerEmail: sub.payer_email ?? "",
156
+ };
157
+ dunningStore.set(ap.preapproval_id, state);
158
+ }
159
+
160
+ await runDunningStep(state, explained);
161
+ }
162
+
163
+ // ─────────────────────────────────────────────────────────────────────────────
164
+ // Dunning state machine
165
+ // ─────────────────────────────────────────────────────────────────────────────
166
+
167
+ async function runDunningStep(
168
+ state: DunningState,
169
+ explained: ReturnType<typeof explainPaymentStatus>,
170
+ ) {
171
+ const elapsedDays = (Date.now() - state.firstFailureAt) / (24 * 60 * 60 * 1000);
172
+
173
+ if (elapsedDays < 3 && state.attemptsSent === 0) {
174
+ // Day 0: friendly heads-up. The buyer can retry the card via the
175
+ // subscription's init_point_url (MP UI handles re-auth).
176
+ await sendMessage(state.buyerEmail, "first-failure", {
177
+ reason: explained.summary,
178
+ retryUrl: await fetchInitPoint(state.subscriptionId),
179
+ });
180
+ state.attemptsSent = 1;
181
+ return;
182
+ }
183
+
184
+ if (elapsedDays >= 3 && elapsedDays < 7 && state.attemptsSent === 1) {
185
+ // Day 3: pause the subscription.
186
+ await mp.pausePreapproval(state.subscriptionId);
187
+ state.status = "paused";
188
+ await sendMessage(state.buyerEmail, "paused", {
189
+ retryUrl: await fetchInitPoint(state.subscriptionId),
190
+ });
191
+ state.attemptsSent = 2;
192
+ return;
193
+ }
194
+
195
+ if (elapsedDays >= 7 && elapsedDays < 14 && state.attemptsSent === 2) {
196
+ // Day 7: retention offer.
197
+ await sendMessage(state.buyerEmail, "retention-offer", {
198
+ offer: "1 mes gratis si volvés en los próximos 7 días",
199
+ retryUrl: await fetchInitPoint(state.subscriptionId),
200
+ });
201
+ state.attemptsSent = 3;
202
+ return;
203
+ }
204
+
205
+ if (elapsedDays >= 14 && state.attemptsSent === 3) {
206
+ // Day 14: cancel.
207
+ // HITL: in production, route this to a human approval queue first.
208
+ // For this recipe, we cancel immediately.
209
+ await mp.cancelPreapproval(state.subscriptionId);
210
+ state.status = "cancelled";
211
+ await sendMessage(state.buyerEmail, "cancelled", {});
212
+ state.attemptsSent = 4;
213
+ return;
214
+ }
215
+ }
216
+
217
+ // ─────────────────────────────────────────────────────────────────────────────
218
+ // Side-effects (replace with your channel of choice)
219
+ // ─────────────────────────────────────────────────────────────────────────────
220
+
221
+ async function fetchInitPoint(subscriptionId: string): Promise<string> {
222
+ const sub = await mp.getPreapproval(subscriptionId);
223
+ return sub.init_point;
224
+ }
225
+
226
+ async function sendMessage(
227
+ email: string,
228
+ template: "first-failure" | "paused" | "retention-offer" | "cancelled",
229
+ data: Record<string, string>,
230
+ ) {
231
+ // In production: compose an email via Resend / Postmark, AND send a
232
+ // WhatsApp via @ar-agents/whatsapp. Keeping this stub here so the recipe
233
+ // is copy-pasteable into any channel.
234
+ console.log(`[dunning] ${template} -> ${email}`, data);
235
+ }
236
+
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+ // Cron job — fallback when webhooks miss
239
+ // ─────────────────────────────────────────────────────────────────────────────
240
+
241
+ /**
242
+ * Run on a daily Vercel Cron. Catches dunning states that didn't progress
243
+ * because the buyer never triggered a webhook (e.g. they ignored the email
244
+ * and didn't retry their card — no event fires until their NEXT scheduled
245
+ * recurring charge).
246
+ *
247
+ * Add to vercel.json:
248
+ *
249
+ * {
250
+ * "crons": [
251
+ * { "path": "/api/cron/dunning-tick", "schedule": "0 9 * * *" }
252
+ * ]
253
+ * }
254
+ */
255
+ export async function dunningTick() {
256
+ for (const state of dunningStore.values()) {
257
+ if (state.status === "cancelled") continue;
258
+ await runDunningStep(
259
+ state,
260
+ explainPaymentStatus({
261
+ id: "tick",
262
+ status: "rejected",
263
+ status_detail: "cc_rejected_call_for_authorize",
264
+ transaction_amount: 0,
265
+ currency_id: "ARS",
266
+ } as unknown as Parameters<typeof explainPaymentStatus>[0]),
267
+ );
268
+ }
269
+ }
270
+
271
+ // ─────────────────────────────────────────────────────────────────────────────
272
+ // Test harness — run with `pnpm tsx cookbook/11-dunning-sequence.ts`
273
+ // ─────────────────────────────────────────────────────────────────────────────
274
+
275
+ async function main() {
276
+ // Simulate a failure event on subscription "abc123".
277
+ const fakeState: DunningState = {
278
+ subscriptionId: "abc123",
279
+ firstFailureAt: Date.now() - 4 * 24 * 60 * 60 * 1000, // 4 days ago
280
+ attemptsSent: 1,
281
+ status: "active",
282
+ buyerEmail: "test@example.com",
283
+ };
284
+ dunningStore.set("abc123", fakeState);
285
+
286
+ await runDunningStep(
287
+ fakeState,
288
+ explainPaymentStatus({
289
+ id: "test",
290
+ status: "rejected",
291
+ status_detail: "cc_rejected_insufficient_amount",
292
+ transaction_amount: 1000,
293
+ currency_id: "ARS",
294
+ } as unknown as Parameters<typeof explainPaymentStatus>[0]),
295
+ );
296
+
297
+ console.log("Dunning state after step:", dunningStore.get("abc123"));
298
+ }
299
+
300
+ if (process.argv[1]?.endsWith("11-dunning-sequence.ts")) {
301
+ main().catch((err: unknown) => {
302
+ console.error(err);
303
+ process.exit(1);
304
+ });
305
+ }