@ar-agents/mercadopago 0.15.3 → 0.17.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/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add `mercadopago doctor` CLI for environment diagnosis.
8
+
9
+ ```bash
10
+ npx @ar-agents/mercadopago doctor
11
+ pnpm exec mercadopago doctor
12
+ ```
13
+
14
+ Reports:
15
+
16
+ - Node version (must be ≥ 20)
17
+ - `MP_ACCESS_TOKEN` presence + format + sandbox/prod prefix detection
18
+ - Live token validation against `GET /users/me` (free, no charge)
19
+ - `NEXT_PUBLIC_BACK_URL` presence + HTTPS check (MP rejects localhost server-side)
20
+ - `MP_WEBHOOK_SECRET` presence + length sanity
21
+ - Peer-dependency installation: `ai`, `zod`, `@vercel/kv`, `@opentelemetry/api`
22
+ - Tool count grouped by inferred category (auto-derived from `tools.manifest.json`)
23
+ - The 8 irreversible ops gated by `requireConfirmation()`
24
+
25
+ Pass `--probe` to additionally dry-call `validate_tax_id` against your sandbox token (also free):
26
+
27
+ ```bash
28
+ npx @ar-agents/mercadopago doctor --probe
29
+ ```
30
+
31
+ Exit codes follow the convention: `0` = ok or warn-only, `1` = at least one fail. CI scripts can `npx @ar-agents/mercadopago doctor` to gate deploys on having credentials wired up.
32
+
33
+ Also exposes `mercadopago help` and `mercadopago version`. Output respects `NO_COLOR`.
34
+
35
+ 10 new subprocess tests (test/cli.test.ts), 328 tests total.
36
+
37
+ ## 0.16.0
38
+
39
+ ### Minor Changes
40
+
41
+ - Add `@ar-agents/mercadopago/testing` subpath: factories + a mock client for tests.
42
+
43
+ What ships:
44
+
45
+ - **Factories**: `mockPayment`, `mockPreapproval`, `mockSubscriptionPayment`, `mockPreference`, `mockRefund`, `mockCustomer`. Each takes a partial overrides object so test setup is one line.
46
+ - **`MockMercadoPagoClient`**: in-memory client with the most-common create/get/cancel/refund paths. Read-only methods that don't fit a clean store model throw `MockNotImplementedError` to nudge users toward MSW or a real sandbox token rather than silently growing the mock.
47
+ - **`mockSignedWebhook`**: produces a `{ headers, searchParams, body }` triple whose `x-signature` header is a real HMAC-SHA256 against the secret you pass. Drops directly into `verifyWebhookSignature` — test the full webhook stack without hand-rolling the signature manifest.
48
+
49
+ Subpath was chosen over the main entry to keep the production bundle clean (the testing helpers are dev-only).
50
+
51
+ ```ts
52
+ import {
53
+ MockMercadoPagoClient,
54
+ mockPayment,
55
+ mockSignedWebhook,
56
+ } from "@ar-agents/mercadopago/testing";
57
+ ```
58
+
59
+ 15 new unit tests (testing-subpath.test.ts), still 100% passing.
60
+
3
61
  ## 0.15.3
4
62
 
5
63
  ### Patch Changes
package/README.md CHANGED
@@ -13,6 +13,7 @@
13
13
  [![npm downloads](https://img.shields.io/npm/dm/@ar-agents/mercadopago.svg)](https://www.npmjs.com/package/@ar-agents/mercadopago)
14
14
  [![license](https://img.shields.io/npm/l/@ar-agents/mercadopago.svg)](./LICENSE)
15
15
  [![CI](https://github.com/ar-agents/ar-agents/actions/workflows/ci.yml/badge.svg)](https://github.com/ar-agents/ar-agents/actions/workflows/ci.yml)
16
+ [![npm provenance](https://img.shields.io/badge/npm%20provenance-SLSA%20v1-7C3AED?logo=npm)](https://docs.npmjs.com/generating-provenance-statements)
16
17
  [![bundle size](https://img.shields.io/bundlephobia/minzip/@ar-agents/mercadopago.svg)](https://bundlephobia.com/package/@ar-agents/mercadopago)
17
18
 
18
19
  Wraps the Mercado Pago API as a typed tool collection for AI agents. Built for
@@ -94,6 +95,14 @@ console.log(result.text);
94
95
  // https://www.mercadopago.com.ar/subscriptions/checkout?preapproval_id=..."
95
96
  ```
96
97
 
98
+ ## Diagnose your setup
99
+
100
+ ```bash
101
+ npx @ar-agents/mercadopago doctor
102
+ ```
103
+
104
+ Validates `MP_ACCESS_TOKEN` against the live API, checks peer deps, lists all 89 tools, surfaces the 8 irreversible operations gated by `requireConfirmation`, and warns on common misconfigurations (missing `NEXT_PUBLIC_BACK_URL`, non-HTTPS back URL, suspiciously short webhook secret, trailing-newline tokens). Pass `--probe` to also dry-call `validate_tax_id` against your sandbox. CI-friendly exit codes (`0` ok, `1` fail).
105
+
97
106
  ## Webhooks
98
107
 
99
108
  MP notifies your endpoint whenever a subscription's status changes. The
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // Thin shim: import the bundled CLI and forward argv. Compiled output lives
3
+ // at dist/cli.js. Keeping this file small means we don't have to rebuild it
4
+ // when CLI logic changes — only the bundle changes.
5
+ import { runCli } from "../dist/cli.js";
6
+
7
+ runCli(process.argv).then(
8
+ (code) => process.exit(code),
9
+ (err) => {
10
+ console.error(err);
11
+ process.exit(1);
12
+ },
13
+ );
@@ -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
+ }