@q32/core 0.2.2 → 0.3.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/README.md +2 -0
- package/dist/conversion-outbox.d.ts +41 -0
- package/dist/conversion-outbox.d.ts.map +1 -0
- package/dist/conversion-outbox.js +54 -0
- package/dist/conversion-outbox.js.map +1 -0
- package/dist/google-ads.d.ts +36 -0
- package/dist/google-ads.d.ts.map +1 -0
- package/dist/google-ads.js +159 -0
- package/dist/google-ads.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/docs/conversion-outbox.md +39 -0
- package/package.json +9 -1
- package/src/conversion-outbox.ts +90 -0
- package/src/google-ads.ts +176 -0
- package/src/index.ts +2 -0
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ The first release focuses on common infrastructure repeated across Q32 apps:
|
|
|
8
8
|
- API operation registries
|
|
9
9
|
- API/MCP discovery surfaces: OpenAPI docs, API catalogs, Agent Skills indexes, MCP manifests, and bearer challenges
|
|
10
10
|
- framework-neutral auth/session/MCP auth services with thin Hono and React Router adapters
|
|
11
|
+
- durable purchase-conversion outbox contracts and Google Ads click-conversion uploads
|
|
11
12
|
- billing plan/status primitives
|
|
12
13
|
- Cloudflare binding guards
|
|
13
14
|
- environment parsing
|
|
@@ -67,6 +68,7 @@ import {
|
|
|
67
68
|
```
|
|
68
69
|
|
|
69
70
|
See [docs/jobs.md](docs/jobs.md) for the shared jobs model.
|
|
71
|
+
See [docs/conversion-outbox.md](docs/conversion-outbox.md) for the confirmed-purchase conversion contract.
|
|
70
72
|
|
|
71
73
|
The root `@q32/core` export is kept dependency-light. Optional integrations that
|
|
72
74
|
need peer packages should be imported from their subpaths, for example
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type ConversionProvider = "google_ads" | "meta" | "microsoft_ads";
|
|
2
|
+
export type ConversionDeliveryStatus = "pending" | "sending" | "sent" | "retry" | "dead" | "skipped";
|
|
3
|
+
export type AdClickIds = {
|
|
4
|
+
gclid?: string | null;
|
|
5
|
+
gbraid?: string | null;
|
|
6
|
+
wbraid?: string | null;
|
|
7
|
+
fbclid?: string | null;
|
|
8
|
+
msclkid?: string | null;
|
|
9
|
+
};
|
|
10
|
+
export type PurchaseConversionEvent = {
|
|
11
|
+
eventId: string;
|
|
12
|
+
orderId: string;
|
|
13
|
+
productSlug: string;
|
|
14
|
+
conversionAt: string;
|
|
15
|
+
value: number;
|
|
16
|
+
currency: string;
|
|
17
|
+
clickIds: AdClickIds;
|
|
18
|
+
emailSha256?: string | null;
|
|
19
|
+
sourceUrl?: string | null;
|
|
20
|
+
metadata?: Record<string, unknown>;
|
|
21
|
+
};
|
|
22
|
+
export type ConversionDelivery = {
|
|
23
|
+
deliveryKey: string;
|
|
24
|
+
eventId: string;
|
|
25
|
+
provider: ConversionProvider;
|
|
26
|
+
status: ConversionDeliveryStatus;
|
|
27
|
+
attemptCount: number;
|
|
28
|
+
nextAttemptAt: string | null;
|
|
29
|
+
sentAt: string | null;
|
|
30
|
+
lastErrorCode: string | null;
|
|
31
|
+
lastErrorMessage: string | null;
|
|
32
|
+
};
|
|
33
|
+
export declare function createPurchaseConversionEvent(input: PurchaseConversionEvent): PurchaseConversionEvent;
|
|
34
|
+
export declare function conversionDeliveryKey(eventId: string, provider: ConversionProvider): string;
|
|
35
|
+
export declare function createConversionDelivery(eventId: string, provider: ConversionProvider): ConversionDelivery;
|
|
36
|
+
export declare function nextConversionRetryAt(attemptCount: number, options?: {
|
|
37
|
+
now?: number;
|
|
38
|
+
baseDelayMs?: number;
|
|
39
|
+
maxDelayMs?: number;
|
|
40
|
+
}): string;
|
|
41
|
+
//# sourceMappingURL=conversion-outbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conversion-outbox.d.ts","sourceRoot":"","sources":["../src/conversion-outbox.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,kBAAkB,GAAG,YAAY,GAAG,MAAM,GAAG,eAAe,CAAC;AACzE,MAAM,MAAM,wBAAwB,GAAG,SAAS,GAAG,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAAC;AAErG,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,UAAU,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,MAAM,EAAE,wBAAwB,CAAC;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC,CAAC;AAEF,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,uBAAuB,GAAG,uBAAuB,CAcrG;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,kBAAkB,GAAG,MAAM,CAG3F;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,kBAAkB,GAAG,kBAAkB,CAY1G;AAED,wBAAgB,qBAAqB,CACnC,YAAY,EAAE,MAAM,EACpB,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GACxE,MAAM,CAMR"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function createPurchaseConversionEvent(input) {
|
|
2
|
+
if (!input.eventId.trim())
|
|
3
|
+
throw new Error("Purchase conversion eventId is required");
|
|
4
|
+
if (!input.orderId.trim())
|
|
5
|
+
throw new Error("Purchase conversion orderId is required");
|
|
6
|
+
if (!input.productSlug.trim())
|
|
7
|
+
throw new Error("Purchase conversion productSlug is required");
|
|
8
|
+
if (!Number.isFinite(input.value) || input.value < 0)
|
|
9
|
+
throw new Error("Purchase conversion value must be non-negative");
|
|
10
|
+
if (!/^[A-Z]{3}$/.test(input.currency))
|
|
11
|
+
throw new Error("Purchase conversion currency must be a three-letter uppercase code");
|
|
12
|
+
if (!Number.isFinite(Date.parse(input.conversionAt)))
|
|
13
|
+
throw new Error("Purchase conversion conversionAt must be an ISO date-time");
|
|
14
|
+
return {
|
|
15
|
+
...input,
|
|
16
|
+
eventId: input.eventId.trim(),
|
|
17
|
+
orderId: input.orderId.trim(),
|
|
18
|
+
productSlug: input.productSlug.trim(),
|
|
19
|
+
clickIds: compactClickIds(input.clickIds),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function conversionDeliveryKey(eventId, provider) {
|
|
23
|
+
if (!eventId.trim())
|
|
24
|
+
throw new Error("Conversion delivery eventId is required");
|
|
25
|
+
return `${eventId.trim()}:${provider}`;
|
|
26
|
+
}
|
|
27
|
+
export function createConversionDelivery(eventId, provider) {
|
|
28
|
+
return {
|
|
29
|
+
deliveryKey: conversionDeliveryKey(eventId, provider),
|
|
30
|
+
eventId: eventId.trim(),
|
|
31
|
+
provider,
|
|
32
|
+
status: "pending",
|
|
33
|
+
attemptCount: 0,
|
|
34
|
+
nextAttemptAt: null,
|
|
35
|
+
sentAt: null,
|
|
36
|
+
lastErrorCode: null,
|
|
37
|
+
lastErrorMessage: null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function nextConversionRetryAt(attemptCount, options = {}) {
|
|
41
|
+
if (!Number.isInteger(attemptCount) || attemptCount < 1)
|
|
42
|
+
throw new Error("Conversion attemptCount must be a positive integer");
|
|
43
|
+
const baseDelayMs = options.baseDelayMs ?? 30_000;
|
|
44
|
+
const maxDelayMs = options.maxDelayMs ?? 24 * 60 * 60 * 1000;
|
|
45
|
+
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.min(attemptCount - 1, 20));
|
|
46
|
+
return new Date((options.now ?? Date.now()) + delay).toISOString();
|
|
47
|
+
}
|
|
48
|
+
function compactClickIds(input) {
|
|
49
|
+
return Object.fromEntries(Object.entries(input).flatMap(([key, value]) => {
|
|
50
|
+
const normalized = value?.trim();
|
|
51
|
+
return normalized ? [[key, normalized]] : [];
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=conversion-outbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"conversion-outbox.js","sourceRoot":"","sources":["../src/conversion-outbox.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FetchLike } from "./http.js";
|
|
2
|
+
import type { PurchaseConversionEvent } from "./conversion-outbox.js";
|
|
3
|
+
export type GoogleAdsConversionConfig = {
|
|
4
|
+
developerToken: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
clientSecret: string;
|
|
7
|
+
refreshToken: string;
|
|
8
|
+
customerId: string;
|
|
9
|
+
conversionActionId: string;
|
|
10
|
+
loginCustomerId?: string;
|
|
11
|
+
apiVersion?: string;
|
|
12
|
+
};
|
|
13
|
+
export type GoogleAdsUploadResult = {
|
|
14
|
+
status: "sent";
|
|
15
|
+
jobId: string | null;
|
|
16
|
+
requestId: string | null;
|
|
17
|
+
} | {
|
|
18
|
+
status: "skipped";
|
|
19
|
+
reason: "missing_google_click_id";
|
|
20
|
+
};
|
|
21
|
+
export declare class GoogleAdsConversionError extends Error {
|
|
22
|
+
readonly code: string;
|
|
23
|
+
readonly retryable: boolean;
|
|
24
|
+
readonly status: number | null;
|
|
25
|
+
constructor(message: string, code: string, retryable: boolean, status: number | null);
|
|
26
|
+
}
|
|
27
|
+
export declare class GoogleAdsConversionClient {
|
|
28
|
+
private readonly config;
|
|
29
|
+
private readonly fetchImpl;
|
|
30
|
+
private accessToken;
|
|
31
|
+
constructor(config: GoogleAdsConversionConfig, fetchImpl?: FetchLike);
|
|
32
|
+
uploadPurchase(event: PurchaseConversionEvent): Promise<GoogleAdsUploadResult>;
|
|
33
|
+
private getAccessToken;
|
|
34
|
+
}
|
|
35
|
+
export declare function googleAdsDateTime(value: string): string;
|
|
36
|
+
//# sourceMappingURL=google-ads.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"google-ads.d.ts","sourceRoot":"","sources":["../src/google-ads.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAE3C,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAKtE,MAAM,MAAM,yBAAyB,GAAG;IACtC,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAC7B;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAClE;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,yBAAyB,CAAA;CAAE,CAAC;AAE7D,qBAAa,wBAAyB,SAAQ,KAAK;IAG/C,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB,QAAQ,CAAC,SAAS,EAAE,OAAO;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;gBAH9B,OAAO,EAAE,MAAM,EACN,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,OAAO,EAClB,MAAM,EAAE,MAAM,GAAG,IAAI;CAKjC;AAED,qBAAa,yBAAyB;IAIlC,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAJ5B,OAAO,CAAC,WAAW,CAAqD;gBAGrD,MAAM,EAAE,yBAAyB,EACjC,SAAS,GAAE,SAAwB;IAGhD,cAAc,CAAC,KAAK,EAAE,uBAAuB,GAAG,OAAO,CAAC,qBAAqB,CAAC;YAuCtE,cAAc;CAsB7B;AAYD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAIvD"}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { defaultFetch } from "./http.js";
|
|
2
|
+
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
3
|
+
const GOOGLE_ADS_ORIGIN = "https://googleads.googleapis.com";
|
|
4
|
+
export class GoogleAdsConversionError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
retryable;
|
|
7
|
+
status;
|
|
8
|
+
constructor(message, code, retryable, status) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.retryable = retryable;
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.name = "GoogleAdsConversionError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class GoogleAdsConversionClient {
|
|
17
|
+
config;
|
|
18
|
+
fetchImpl;
|
|
19
|
+
accessToken = null;
|
|
20
|
+
constructor(config, fetchImpl = defaultFetch) {
|
|
21
|
+
this.config = config;
|
|
22
|
+
this.fetchImpl = fetchImpl;
|
|
23
|
+
}
|
|
24
|
+
async uploadPurchase(event) {
|
|
25
|
+
const clickId = googleClickId(event);
|
|
26
|
+
if (!clickId)
|
|
27
|
+
return { status: "skipped", reason: "missing_google_click_id" };
|
|
28
|
+
const accessToken = await this.getAccessToken();
|
|
29
|
+
const customerId = digitsOnly(this.config.customerId, "customerId");
|
|
30
|
+
const actionId = digitsOnly(this.config.conversionActionId, "conversionActionId");
|
|
31
|
+
const apiVersion = this.config.apiVersion ?? "v24";
|
|
32
|
+
const conversion = {
|
|
33
|
+
conversionAction: `customers/${customerId}/conversionActions/${actionId}`,
|
|
34
|
+
conversionDateTime: googleAdsDateTime(event.conversionAt),
|
|
35
|
+
conversionValue: event.value,
|
|
36
|
+
currencyCode: event.currency,
|
|
37
|
+
orderId: event.orderId,
|
|
38
|
+
conversionEnvironment: "WEB",
|
|
39
|
+
[clickId.kind]: clickId.value,
|
|
40
|
+
};
|
|
41
|
+
const headers = {
|
|
42
|
+
authorization: `Bearer ${accessToken}`,
|
|
43
|
+
"content-type": "application/json",
|
|
44
|
+
"developer-token": this.config.developerToken,
|
|
45
|
+
};
|
|
46
|
+
if (this.config.loginCustomerId) {
|
|
47
|
+
headers["login-customer-id"] = digitsOnly(this.config.loginCustomerId, "loginCustomerId");
|
|
48
|
+
}
|
|
49
|
+
const response = await this.fetchImpl(`${GOOGLE_ADS_ORIGIN}/${apiVersion}/customers/${customerId}:uploadClickConversions`, { method: "POST", headers, body: JSON.stringify({ conversions: [conversion], partialFailure: true }) });
|
|
50
|
+
const payload = await readJson(response);
|
|
51
|
+
if (!response.ok)
|
|
52
|
+
throw googleAdsHttpError(response.status, payload);
|
|
53
|
+
const partialFailure = recordValue(payload, "partialFailureError");
|
|
54
|
+
if (partialFailure)
|
|
55
|
+
throw googleAdsPartialFailure(partialFailure);
|
|
56
|
+
return {
|
|
57
|
+
status: "sent",
|
|
58
|
+
jobId: stringValue(payload, "jobId"),
|
|
59
|
+
requestId: response.headers.get("request-id"),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async getAccessToken() {
|
|
63
|
+
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000)
|
|
64
|
+
return this.accessToken.value;
|
|
65
|
+
const response = await this.fetchImpl(GOOGLE_TOKEN_URL, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
68
|
+
body: new URLSearchParams({
|
|
69
|
+
client_id: this.config.clientId,
|
|
70
|
+
client_secret: this.config.clientSecret,
|
|
71
|
+
refresh_token: this.config.refreshToken,
|
|
72
|
+
grant_type: "refresh_token",
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
const payload = await readJson(response);
|
|
76
|
+
const token = stringValue(payload, "access_token");
|
|
77
|
+
if (!response.ok || !token) {
|
|
78
|
+
const code = stringValue(payload, "error") ?? "oauth_refresh_failed";
|
|
79
|
+
throw new GoogleAdsConversionError(`Google OAuth refresh failed: ${code}`, code, response.status >= 500, response.status);
|
|
80
|
+
}
|
|
81
|
+
const expiresIn = numberValue(payload, "expires_in") ?? 3600;
|
|
82
|
+
this.accessToken = { value: token, expiresAt: Date.now() + expiresIn * 1000 };
|
|
83
|
+
return token;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function googleClickId(event) {
|
|
87
|
+
const candidates = [
|
|
88
|
+
["gclid", event.clickIds.gclid],
|
|
89
|
+
["wbraid", event.clickIds.wbraid],
|
|
90
|
+
["gbraid", event.clickIds.gbraid],
|
|
91
|
+
];
|
|
92
|
+
for (const [kind, value] of candidates)
|
|
93
|
+
if (value?.trim())
|
|
94
|
+
return { kind, value: value.trim() };
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
export function googleAdsDateTime(value) {
|
|
98
|
+
const date = new Date(value);
|
|
99
|
+
if (!Number.isFinite(date.getTime()))
|
|
100
|
+
throw new Error("Google Ads conversion time must be a valid date-time");
|
|
101
|
+
return date.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "+00:00");
|
|
102
|
+
}
|
|
103
|
+
function digitsOnly(value, field) {
|
|
104
|
+
const normalized = value.replaceAll("-", "").trim();
|
|
105
|
+
if (!/^\d+$/.test(normalized))
|
|
106
|
+
throw new Error(`Google Ads ${field} must contain only digits`);
|
|
107
|
+
return normalized;
|
|
108
|
+
}
|
|
109
|
+
async function readJson(response) {
|
|
110
|
+
return (await response.json().catch(() => ({})));
|
|
111
|
+
}
|
|
112
|
+
function googleAdsHttpError(status, payload) {
|
|
113
|
+
const error = recordValue(payload, "error");
|
|
114
|
+
const code = stringValue(error ?? payload, "status") ?? `http_${status}`;
|
|
115
|
+
const message = stringValue(error ?? payload, "message") ?? `Google Ads upload failed with HTTP ${status}`;
|
|
116
|
+
return new GoogleAdsConversionError(message, code, status === 429 || status >= 500, status);
|
|
117
|
+
}
|
|
118
|
+
function googleAdsPartialFailure(error) {
|
|
119
|
+
const code = firstGoogleAdsErrorCode(error) ?? "partial_failure";
|
|
120
|
+
const message = stringValue(error, "message") ?? "Google Ads rejected the conversion";
|
|
121
|
+
const retryable = ["INTERNAL", "RESOURCE_EXHAUSTED", "UNAVAILABLE", "TOO_RECENT_CONVERSION_ACTION"].includes(code);
|
|
122
|
+
return new GoogleAdsConversionError(message, code, retryable, 200);
|
|
123
|
+
}
|
|
124
|
+
function firstGoogleAdsErrorCode(error) {
|
|
125
|
+
const details = error.details;
|
|
126
|
+
if (!Array.isArray(details))
|
|
127
|
+
return null;
|
|
128
|
+
for (const detail of details) {
|
|
129
|
+
if (!detail || typeof detail !== "object")
|
|
130
|
+
continue;
|
|
131
|
+
const errors = detail.errors;
|
|
132
|
+
if (!Array.isArray(errors))
|
|
133
|
+
continue;
|
|
134
|
+
for (const item of errors) {
|
|
135
|
+
if (!item || typeof item !== "object")
|
|
136
|
+
continue;
|
|
137
|
+
const errorCode = recordValue(item, "errorCode");
|
|
138
|
+
if (!errorCode)
|
|
139
|
+
continue;
|
|
140
|
+
for (const value of Object.values(errorCode))
|
|
141
|
+
if (typeof value === "string")
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
function recordValue(input, key) {
|
|
148
|
+
const value = input[key];
|
|
149
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
150
|
+
}
|
|
151
|
+
function stringValue(input, key) {
|
|
152
|
+
const value = input[key];
|
|
153
|
+
return typeof value === "string" ? value : typeof value === "number" ? String(value) : null;
|
|
154
|
+
}
|
|
155
|
+
function numberValue(input, key) {
|
|
156
|
+
const value = input[key];
|
|
157
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=google-ads.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"google-ads.js","sourceRoot":"","sources":["../src/google-ads.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,12 +4,14 @@ export * from "./auth.js";
|
|
|
4
4
|
export * from "./aws.js";
|
|
5
5
|
export * from "./billing.js";
|
|
6
6
|
export * from "./cloudflare.js";
|
|
7
|
+
export * from "./conversion-outbox.js";
|
|
7
8
|
export * from "./crypto.js";
|
|
8
9
|
export * from "./d1.js";
|
|
9
10
|
export * from "./encoding.js";
|
|
10
11
|
export * from "./email.js";
|
|
11
12
|
export * from "./env.js";
|
|
12
13
|
export * from "./hono.js";
|
|
14
|
+
export * from "./google-ads.js";
|
|
13
15
|
export * from "./http.js";
|
|
14
16
|
export * from "./ids.js";
|
|
15
17
|
export * from "./jobs.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,SAAS,CAAC;AACxB,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,wBAAwB,CAAC;AACvC,cAAc,aAAa,CAAC;AAC5B,cAAc,SAAS,CAAC;AACxB,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -4,12 +4,14 @@ export * from "./auth.js";
|
|
|
4
4
|
export * from "./aws.js";
|
|
5
5
|
export * from "./billing.js";
|
|
6
6
|
export * from "./cloudflare.js";
|
|
7
|
+
export * from "./conversion-outbox.js";
|
|
7
8
|
export * from "./crypto.js";
|
|
8
9
|
export * from "./d1.js";
|
|
9
10
|
export * from "./encoding.js";
|
|
10
11
|
export * from "./email.js";
|
|
11
12
|
export * from "./env.js";
|
|
12
13
|
export * from "./hono.js";
|
|
14
|
+
export * from "./google-ads.js";
|
|
13
15
|
export * from "./http.js";
|
|
14
16
|
export * from "./ids.js";
|
|
15
17
|
export * from "./jobs.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,SAAS,CAAC;AACxB,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,wBAAwB,CAAC;AACvC,cAAc,aAAa,CAAC;AAC5B,cAAc,SAAS,CAAC;AACxB,cAAc,eAAe,CAAC;AAC9B,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,SAAS,CAAC;AACxB,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Purchase conversion outbox
|
|
2
|
+
|
|
3
|
+
Commit the product purchase and its conversion event before calling an ad provider. Create one delivery per configured provider, acknowledge the verified payment webhook, and retry deliveries independently.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createConversionDelivery, createPurchaseConversionEvent } from "@q32/core/conversion-outbox";
|
|
7
|
+
import { GoogleAdsConversionClient } from "@q32/core/google-ads";
|
|
8
|
+
|
|
9
|
+
const purchase = createPurchaseConversionEvent({
|
|
10
|
+
eventId: `purchase:${order.id}`,
|
|
11
|
+
orderId: order.id,
|
|
12
|
+
productSlug: "example",
|
|
13
|
+
conversionAt: order.paidAt,
|
|
14
|
+
value: order.valueCents / 100,
|
|
15
|
+
currency: order.currency.toUpperCase(),
|
|
16
|
+
clickIds: { gclid: attribution.gclid, gbraid: attribution.gbraid, wbraid: attribution.wbraid },
|
|
17
|
+
});
|
|
18
|
+
const googleDelivery = createConversionDelivery(purchase.eventId, "google_ads");
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Persist both records atomically with the purchase. The package intentionally does not prescribe D1 or Postgres table names.
|
|
22
|
+
|
|
23
|
+
Upload with a client shared by the Worker isolate:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
const google = new GoogleAdsConversionClient({
|
|
27
|
+
developerToken: env.GOOGLE_ADS_DEVELOPER_TOKEN,
|
|
28
|
+
clientId: env.GOOGLE_ADS_CLIENT_ID,
|
|
29
|
+
clientSecret: env.GOOGLE_ADS_CLIENT_SECRET,
|
|
30
|
+
refreshToken: env.GOOGLE_ADS_REFRESH_TOKEN,
|
|
31
|
+
customerId: env.GOOGLE_ADS_CUSTOMER_ID,
|
|
32
|
+
loginCustomerId: env.GOOGLE_ADS_LOGIN_CUSTOMER_ID,
|
|
33
|
+
conversionActionId: env.GOOGLE_ADS_PURCHASE_CONVERSION_ACTION_ID,
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`uploadPurchase` returns `skipped` when no Google click identifier is available. It throws `GoogleAdsConversionError` for provider failures; `retryable` distinguishes transient failures from permanent rejection. Store bounded error details and schedule retry with `nextConversionRetryAt`.
|
|
38
|
+
|
|
39
|
+
Google requires an `UPLOAD_CLICKS` conversion action, `partialFailure: true`, and a unique order ID per conversion action. Keep browser purchase events when useful for diagnostics, but do not make the durable upload depend on a success-page visit.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@q32/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shared TypeScript primitives for Q32 Cloudflare Worker projects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
"types": "./dist/cloudflare.d.ts",
|
|
45
45
|
"default": "./dist/cloudflare.js"
|
|
46
46
|
},
|
|
47
|
+
"./conversion-outbox": {
|
|
48
|
+
"types": "./dist/conversion-outbox.d.ts",
|
|
49
|
+
"default": "./dist/conversion-outbox.js"
|
|
50
|
+
},
|
|
47
51
|
"./crypto": {
|
|
48
52
|
"types": "./dist/crypto.d.ts",
|
|
49
53
|
"default": "./dist/crypto.js"
|
|
@@ -72,6 +76,10 @@
|
|
|
72
76
|
"types": "./dist/hono.d.ts",
|
|
73
77
|
"default": "./dist/hono.js"
|
|
74
78
|
},
|
|
79
|
+
"./google-ads": {
|
|
80
|
+
"types": "./dist/google-ads.d.ts",
|
|
81
|
+
"default": "./dist/google-ads.js"
|
|
82
|
+
},
|
|
75
83
|
"./http": {
|
|
76
84
|
"types": "./dist/http.d.ts",
|
|
77
85
|
"default": "./dist/http.js"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export type ConversionProvider = "google_ads" | "meta" | "microsoft_ads";
|
|
2
|
+
export type ConversionDeliveryStatus = "pending" | "sending" | "sent" | "retry" | "dead" | "skipped";
|
|
3
|
+
|
|
4
|
+
export type AdClickIds = {
|
|
5
|
+
gclid?: string | null;
|
|
6
|
+
gbraid?: string | null;
|
|
7
|
+
wbraid?: string | null;
|
|
8
|
+
fbclid?: string | null;
|
|
9
|
+
msclkid?: string | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type PurchaseConversionEvent = {
|
|
13
|
+
eventId: string;
|
|
14
|
+
orderId: string;
|
|
15
|
+
productSlug: string;
|
|
16
|
+
conversionAt: string;
|
|
17
|
+
value: number;
|
|
18
|
+
currency: string;
|
|
19
|
+
clickIds: AdClickIds;
|
|
20
|
+
emailSha256?: string | null;
|
|
21
|
+
sourceUrl?: string | null;
|
|
22
|
+
metadata?: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ConversionDelivery = {
|
|
26
|
+
deliveryKey: string;
|
|
27
|
+
eventId: string;
|
|
28
|
+
provider: ConversionProvider;
|
|
29
|
+
status: ConversionDeliveryStatus;
|
|
30
|
+
attemptCount: number;
|
|
31
|
+
nextAttemptAt: string | null;
|
|
32
|
+
sentAt: string | null;
|
|
33
|
+
lastErrorCode: string | null;
|
|
34
|
+
lastErrorMessage: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function createPurchaseConversionEvent(input: PurchaseConversionEvent): PurchaseConversionEvent {
|
|
38
|
+
if (!input.eventId.trim()) throw new Error("Purchase conversion eventId is required");
|
|
39
|
+
if (!input.orderId.trim()) throw new Error("Purchase conversion orderId is required");
|
|
40
|
+
if (!input.productSlug.trim()) throw new Error("Purchase conversion productSlug is required");
|
|
41
|
+
if (!Number.isFinite(input.value) || input.value < 0) throw new Error("Purchase conversion value must be non-negative");
|
|
42
|
+
if (!/^[A-Z]{3}$/.test(input.currency)) throw new Error("Purchase conversion currency must be a three-letter uppercase code");
|
|
43
|
+
if (!Number.isFinite(Date.parse(input.conversionAt))) throw new Error("Purchase conversion conversionAt must be an ISO date-time");
|
|
44
|
+
return {
|
|
45
|
+
...input,
|
|
46
|
+
eventId: input.eventId.trim(),
|
|
47
|
+
orderId: input.orderId.trim(),
|
|
48
|
+
productSlug: input.productSlug.trim(),
|
|
49
|
+
clickIds: compactClickIds(input.clickIds),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function conversionDeliveryKey(eventId: string, provider: ConversionProvider): string {
|
|
54
|
+
if (!eventId.trim()) throw new Error("Conversion delivery eventId is required");
|
|
55
|
+
return `${eventId.trim()}:${provider}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createConversionDelivery(eventId: string, provider: ConversionProvider): ConversionDelivery {
|
|
59
|
+
return {
|
|
60
|
+
deliveryKey: conversionDeliveryKey(eventId, provider),
|
|
61
|
+
eventId: eventId.trim(),
|
|
62
|
+
provider,
|
|
63
|
+
status: "pending",
|
|
64
|
+
attemptCount: 0,
|
|
65
|
+
nextAttemptAt: null,
|
|
66
|
+
sentAt: null,
|
|
67
|
+
lastErrorCode: null,
|
|
68
|
+
lastErrorMessage: null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function nextConversionRetryAt(
|
|
73
|
+
attemptCount: number,
|
|
74
|
+
options: { now?: number; baseDelayMs?: number; maxDelayMs?: number } = {},
|
|
75
|
+
): string {
|
|
76
|
+
if (!Number.isInteger(attemptCount) || attemptCount < 1) throw new Error("Conversion attemptCount must be a positive integer");
|
|
77
|
+
const baseDelayMs = options.baseDelayMs ?? 30_000;
|
|
78
|
+
const maxDelayMs = options.maxDelayMs ?? 24 * 60 * 60 * 1000;
|
|
79
|
+
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.min(attemptCount - 1, 20));
|
|
80
|
+
return new Date((options.now ?? Date.now()) + delay).toISOString();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function compactClickIds(input: AdClickIds): AdClickIds {
|
|
84
|
+
return Object.fromEntries(
|
|
85
|
+
Object.entries(input).flatMap(([key, value]) => {
|
|
86
|
+
const normalized = value?.trim();
|
|
87
|
+
return normalized ? [[key, normalized]] : [];
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { FetchLike } from "./http.js";
|
|
2
|
+
import { defaultFetch } from "./http.js";
|
|
3
|
+
import type { PurchaseConversionEvent } from "./conversion-outbox.js";
|
|
4
|
+
|
|
5
|
+
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
6
|
+
const GOOGLE_ADS_ORIGIN = "https://googleads.googleapis.com";
|
|
7
|
+
|
|
8
|
+
export type GoogleAdsConversionConfig = {
|
|
9
|
+
developerToken: string;
|
|
10
|
+
clientId: string;
|
|
11
|
+
clientSecret: string;
|
|
12
|
+
refreshToken: string;
|
|
13
|
+
customerId: string;
|
|
14
|
+
conversionActionId: string;
|
|
15
|
+
loginCustomerId?: string;
|
|
16
|
+
apiVersion?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type GoogleAdsUploadResult =
|
|
20
|
+
| { status: "sent"; jobId: string | null; requestId: string | null }
|
|
21
|
+
| { status: "skipped"; reason: "missing_google_click_id" };
|
|
22
|
+
|
|
23
|
+
export class GoogleAdsConversionError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
message: string,
|
|
26
|
+
readonly code: string,
|
|
27
|
+
readonly retryable: boolean,
|
|
28
|
+
readonly status: number | null,
|
|
29
|
+
) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "GoogleAdsConversionError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class GoogleAdsConversionClient {
|
|
36
|
+
private accessToken: { value: string; expiresAt: number } | null = null;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly config: GoogleAdsConversionConfig,
|
|
40
|
+
private readonly fetchImpl: FetchLike = defaultFetch,
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
async uploadPurchase(event: PurchaseConversionEvent): Promise<GoogleAdsUploadResult> {
|
|
44
|
+
const clickId = googleClickId(event);
|
|
45
|
+
if (!clickId) return { status: "skipped", reason: "missing_google_click_id" };
|
|
46
|
+
const accessToken = await this.getAccessToken();
|
|
47
|
+
const customerId = digitsOnly(this.config.customerId, "customerId");
|
|
48
|
+
const actionId = digitsOnly(this.config.conversionActionId, "conversionActionId");
|
|
49
|
+
const apiVersion = this.config.apiVersion ?? "v24";
|
|
50
|
+
const conversion = {
|
|
51
|
+
conversionAction: `customers/${customerId}/conversionActions/${actionId}`,
|
|
52
|
+
conversionDateTime: googleAdsDateTime(event.conversionAt),
|
|
53
|
+
conversionValue: event.value,
|
|
54
|
+
currencyCode: event.currency,
|
|
55
|
+
orderId: event.orderId,
|
|
56
|
+
conversionEnvironment: "WEB",
|
|
57
|
+
[clickId.kind]: clickId.value,
|
|
58
|
+
};
|
|
59
|
+
const headers: Record<string, string> = {
|
|
60
|
+
authorization: `Bearer ${accessToken}`,
|
|
61
|
+
"content-type": "application/json",
|
|
62
|
+
"developer-token": this.config.developerToken,
|
|
63
|
+
};
|
|
64
|
+
if (this.config.loginCustomerId) {
|
|
65
|
+
headers["login-customer-id"] = digitsOnly(this.config.loginCustomerId, "loginCustomerId");
|
|
66
|
+
}
|
|
67
|
+
const response = await this.fetchImpl(
|
|
68
|
+
`${GOOGLE_ADS_ORIGIN}/${apiVersion}/customers/${customerId}:uploadClickConversions`,
|
|
69
|
+
{ method: "POST", headers, body: JSON.stringify({ conversions: [conversion], partialFailure: true }) },
|
|
70
|
+
);
|
|
71
|
+
const payload = await readJson(response);
|
|
72
|
+
if (!response.ok) throw googleAdsHttpError(response.status, payload);
|
|
73
|
+
const partialFailure = recordValue(payload, "partialFailureError");
|
|
74
|
+
if (partialFailure) throw googleAdsPartialFailure(partialFailure);
|
|
75
|
+
return {
|
|
76
|
+
status: "sent",
|
|
77
|
+
jobId: stringValue(payload, "jobId"),
|
|
78
|
+
requestId: response.headers.get("request-id"),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async getAccessToken(): Promise<string> {
|
|
83
|
+
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) return this.accessToken.value;
|
|
84
|
+
const response = await this.fetchImpl(GOOGLE_TOKEN_URL, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
87
|
+
body: new URLSearchParams({
|
|
88
|
+
client_id: this.config.clientId,
|
|
89
|
+
client_secret: this.config.clientSecret,
|
|
90
|
+
refresh_token: this.config.refreshToken,
|
|
91
|
+
grant_type: "refresh_token",
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
const payload = await readJson(response);
|
|
95
|
+
const token = stringValue(payload, "access_token");
|
|
96
|
+
if (!response.ok || !token) {
|
|
97
|
+
const code = stringValue(payload, "error") ?? "oauth_refresh_failed";
|
|
98
|
+
throw new GoogleAdsConversionError(`Google OAuth refresh failed: ${code}`, code, response.status >= 500, response.status);
|
|
99
|
+
}
|
|
100
|
+
const expiresIn = numberValue(payload, "expires_in") ?? 3600;
|
|
101
|
+
this.accessToken = { value: token, expiresAt: Date.now() + expiresIn * 1000 };
|
|
102
|
+
return token;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function googleClickId(event: PurchaseConversionEvent): { kind: "gclid" | "wbraid" | "gbraid"; value: string } | null {
|
|
107
|
+
const candidates = [
|
|
108
|
+
["gclid", event.clickIds.gclid],
|
|
109
|
+
["wbraid", event.clickIds.wbraid],
|
|
110
|
+
["gbraid", event.clickIds.gbraid],
|
|
111
|
+
] as const;
|
|
112
|
+
for (const [kind, value] of candidates) if (value?.trim()) return { kind, value: value.trim() };
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function googleAdsDateTime(value: string): string {
|
|
117
|
+
const date = new Date(value);
|
|
118
|
+
if (!Number.isFinite(date.getTime())) throw new Error("Google Ads conversion time must be a valid date-time");
|
|
119
|
+
return date.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "+00:00");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function digitsOnly(value: string, field: string): string {
|
|
123
|
+
const normalized = value.replaceAll("-", "").trim();
|
|
124
|
+
if (!/^\d+$/.test(normalized)) throw new Error(`Google Ads ${field} must contain only digits`);
|
|
125
|
+
return normalized;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readJson(response: Response): Promise<Record<string, unknown>> {
|
|
129
|
+
return (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function googleAdsHttpError(status: number, payload: Record<string, unknown>): GoogleAdsConversionError {
|
|
133
|
+
const error = recordValue(payload, "error");
|
|
134
|
+
const code = stringValue(error ?? payload, "status") ?? `http_${status}`;
|
|
135
|
+
const message = stringValue(error ?? payload, "message") ?? `Google Ads upload failed with HTTP ${status}`;
|
|
136
|
+
return new GoogleAdsConversionError(message, code, status === 429 || status >= 500, status);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function googleAdsPartialFailure(error: Record<string, unknown>): GoogleAdsConversionError {
|
|
140
|
+
const code = firstGoogleAdsErrorCode(error) ?? "partial_failure";
|
|
141
|
+
const message = stringValue(error, "message") ?? "Google Ads rejected the conversion";
|
|
142
|
+
const retryable = ["INTERNAL", "RESOURCE_EXHAUSTED", "UNAVAILABLE", "TOO_RECENT_CONVERSION_ACTION"].includes(code);
|
|
143
|
+
return new GoogleAdsConversionError(message, code, retryable, 200);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function firstGoogleAdsErrorCode(error: Record<string, unknown>): string | null {
|
|
147
|
+
const details = error.details;
|
|
148
|
+
if (!Array.isArray(details)) return null;
|
|
149
|
+
for (const detail of details) {
|
|
150
|
+
if (!detail || typeof detail !== "object") continue;
|
|
151
|
+
const errors = (detail as Record<string, unknown>).errors;
|
|
152
|
+
if (!Array.isArray(errors)) continue;
|
|
153
|
+
for (const item of errors) {
|
|
154
|
+
if (!item || typeof item !== "object") continue;
|
|
155
|
+
const errorCode = recordValue(item as Record<string, unknown>, "errorCode");
|
|
156
|
+
if (!errorCode) continue;
|
|
157
|
+
for (const value of Object.values(errorCode)) if (typeof value === "string") return value;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function recordValue(input: Record<string, unknown>, key: string): Record<string, unknown> | null {
|
|
164
|
+
const value = input[key];
|
|
165
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function stringValue(input: Record<string, unknown>, key: string): string | null {
|
|
169
|
+
const value = input[key];
|
|
170
|
+
return typeof value === "string" ? value : typeof value === "number" ? String(value) : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function numberValue(input: Record<string, unknown>, key: string): number | null {
|
|
174
|
+
const value = input[key];
|
|
175
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
176
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,14 @@ export * from "./auth.js";
|
|
|
4
4
|
export * from "./aws.js";
|
|
5
5
|
export * from "./billing.js";
|
|
6
6
|
export * from "./cloudflare.js";
|
|
7
|
+
export * from "./conversion-outbox.js";
|
|
7
8
|
export * from "./crypto.js";
|
|
8
9
|
export * from "./d1.js";
|
|
9
10
|
export * from "./encoding.js";
|
|
10
11
|
export * from "./email.js";
|
|
11
12
|
export * from "./env.js";
|
|
12
13
|
export * from "./hono.js";
|
|
14
|
+
export * from "./google-ads.js";
|
|
13
15
|
export * from "./http.js";
|
|
14
16
|
export * from "./ids.js";
|
|
15
17
|
export * from "./jobs.js";
|