@ar-agents/mercadopago 0.15.3 → 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.
- package/CHANGELOG.md +24 -0
- package/README.md +1 -0
- package/cookbook/10-cross-package-billing.ts +172 -0
- package/cookbook/11-dunning-sequence.ts +305 -0
- package/cookbook/12-reconciliation-pipeline.ts +277 -0
- package/cookbook/README.md +3 -0
- package/dist/index.d.cts +4 -1084
- package/dist/index.d.ts +4 -1084
- package/dist/testing.cjs +281 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +188 -0
- package/dist/testing.d.ts +188 -0
- package/dist/testing.js +270 -0
- package/dist/testing.js.map +1 -0
- package/dist/types-BaOjfcOt.d.cts +1085 -0
- package/dist/types-BaOjfcOt.d.ts +1085 -0
- package/package.json +14 -1
- package/tools.manifest.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add `@ar-agents/mercadopago/testing` subpath: factories + a mock client for tests.
|
|
8
|
+
|
|
9
|
+
What ships:
|
|
10
|
+
|
|
11
|
+
- **Factories**: `mockPayment`, `mockPreapproval`, `mockSubscriptionPayment`, `mockPreference`, `mockRefund`, `mockCustomer`. Each takes a partial overrides object so test setup is one line.
|
|
12
|
+
- **`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.
|
|
13
|
+
- **`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.
|
|
14
|
+
|
|
15
|
+
Subpath was chosen over the main entry to keep the production bundle clean (the testing helpers are dev-only).
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import {
|
|
19
|
+
MockMercadoPagoClient,
|
|
20
|
+
mockPayment,
|
|
21
|
+
mockSignedWebhook,
|
|
22
|
+
} from "@ar-agents/mercadopago/testing";
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
15 new unit tests (testing-subpath.test.ts), still 100% passing.
|
|
26
|
+
|
|
3
27
|
## 0.15.3
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
[](https://www.npmjs.com/package/@ar-agents/mercadopago)
|
|
14
14
|
[](./LICENSE)
|
|
15
15
|
[](https://github.com/ar-agents/ar-agents/actions/workflows/ci.yml)
|
|
16
|
+
[](https://docs.npmjs.com/generating-provenance-statements)
|
|
16
17
|
[](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
|
|
@@ -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
|
+
}
|