@owlmeans/server-payment 0.1.18-rc.20 → 0.1.18-rc.21
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 +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +402 -260
- package/build/config.d.ts +15 -1
- package/build/config.d.ts.map +1 -1
- package/build/config.js +99 -3
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +27 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +27 -0
- package/build/consts.js.map +1 -1
- package/build/consumer/capture.d.ts +74 -0
- package/build/consumer/capture.d.ts.map +1 -0
- package/build/consumer/capture.js +291 -0
- package/build/consumer/capture.js.map +1 -0
- package/build/consumer/format.d.ts +27 -0
- package/build/consumer/format.d.ts.map +1 -0
- package/build/consumer/format.js +81 -0
- package/build/consumer/format.js.map +1 -0
- package/build/consumer/handlers.d.ts +28 -0
- package/build/consumer/handlers.d.ts.map +1 -0
- package/build/consumer/handlers.js +173 -0
- package/build/consumer/handlers.js.map +1 -0
- package/build/consumer/index.d.ts +7 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +6 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/mail.d.ts +27 -0
- package/build/consumer/mail.d.ts.map +1 -0
- package/build/consumer/mail.js +314 -0
- package/build/consumer/mail.js.map +1 -0
- package/build/consumer/origin.d.ts +14 -0
- package/build/consumer/origin.d.ts.map +1 -0
- package/build/consumer/origin.js +47 -0
- package/build/consumer/origin.js.map +1 -0
- package/build/consumer/reconcile.d.ts +12 -0
- package/build/consumer/reconcile.d.ts.map +1 -0
- package/build/consumer/reconcile.js +317 -0
- package/build/consumer/reconcile.js.map +1 -0
- package/build/consumer/records.d.ts +78 -0
- package/build/consumer/records.d.ts.map +1 -0
- package/build/consumer/records.js +296 -0
- package/build/consumer/records.js.map +1 -0
- package/build/consumer/service.d.ts +51 -0
- package/build/consumer/service.d.ts.map +1 -0
- package/build/consumer/service.js +760 -0
- package/build/consumer/service.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +57 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +247 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/index.d.ts +4 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -2
- package/build/index.js.map +1 -1
- package/build/model.d.ts +6 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +134 -4
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +4 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +13 -0
- package/build/observer.js.map +1 -1
- package/build/plugins/checkout-plugins.d.ts +49 -0
- package/build/plugins/checkout-plugins.d.ts.map +1 -0
- package/build/plugins/checkout-plugins.js +124 -0
- package/build/plugins/checkout-plugins.js.map +1 -0
- package/build/plugins/estimate.d.ts.map +1 -1
- package/build/plugins/estimate.js +41 -8
- package/build/plugins/estimate.js.map +1 -1
- package/build/plugins/events.d.ts +2 -0
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +126 -9
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +5 -0
- package/build/plugins/fx.d.ts.map +1 -1
- package/build/plugins/fx.js +25 -0
- package/build/plugins/fx.js.map +1 -1
- package/build/plugins/portal.d.ts.map +1 -1
- package/build/plugins/portal.js +11 -1
- package/build/plugins/portal.js.map +1 -1
- package/build/plugins/stripe.d.ts +21 -2
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +397 -61
- package/build/plugins/stripe.js.map +1 -1
- package/build/resource.d.ts +8 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +59 -2
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +2 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +28 -5
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +6 -0
- package/build/subscription.d.ts.map +1 -1
- package/build/subscription.js +1 -0
- package/build/subscription.js.map +1 -1
- package/build/sync.d.ts +7 -0
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +108 -15
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +707 -4
- package/build/types.d.ts.map +1 -1
- package/build/utils.d.ts +22 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +27 -1
- package/build/utils.js.map +1 -1
- package/package.json +14 -13
- package/src/config.ts +111 -7
- package/src/consts.ts +34 -0
- package/src/consumer/capture.ts +362 -0
- package/src/consumer/format.ts +90 -0
- package/src/consumer/handlers.ts +211 -0
- package/src/consumer/index.ts +6 -0
- package/src/consumer/mail.ts +368 -0
- package/src/consumer/origin.ts +63 -0
- package/src/consumer/reconcile.ts +329 -0
- package/src/consumer/records.ts +374 -0
- package/src/consumer/service.ts +868 -0
- package/src/consumer/withdrawal.ts +302 -0
- package/src/index.ts +6 -4
- package/src/model.ts +148 -6
- package/src/observer.ts +15 -2
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +49 -9
- package/src/plugins/events.ts +135 -11
- package/src/plugins/fx.ts +29 -0
- package/src/plugins/portal.ts +11 -1
- package/src/plugins/stripe.ts +476 -60
- package/src/resource.ts +87 -4
- package/src/service.ts +28 -6
- package/src/subscription.ts +7 -0
- package/src/sync.ts +124 -17
- package/src/types.ts +756 -6
- package/src/utils.ts +56 -7
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- package/tests/consumer-events.spec.ts +218 -0
- package/tests/consumer-fixtures.ts +132 -0
- package/tests/consumer-ops.spec.ts +351 -0
- package/tests/consumer-rights.integration.spec.ts +150 -0
- package/tests/consumer-rights.spec.ts +501 -0
- package/tests/context.ts +20 -2
- package/tests/fake-stripe.ts +188 -18
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import type Stripe from 'stripe'
|
|
2
|
+
import { createLazyService } from '@owlmeans/context'
|
|
3
|
+
import { MAILER_SERVICE } from '@owlmeans/mailer'
|
|
4
|
+
import {
|
|
5
|
+
cancellationEffectiveAt, CancellationKind, CancellationStatus, CancellationUnavailable,
|
|
6
|
+
CancellationUnavailableReason, ConsentKind, consentStatementOf, CONSUMER_RIGHTS_COPY_VERSION, ConsumerRightsError,
|
|
7
|
+
DeclarationChannel, DeclarationKind, ENTITLING_STATUSES, inScope, linksOf, PaygateError,
|
|
8
|
+
PerformanceConsentRequired, PurchaseKind, SubscriptionStartRequired, TERMINAL_STATUSES, UnknownPlan,
|
|
9
|
+
WithdrawalStatus, WithdrawalUnavailable, WithdrawalUnavailableReason,
|
|
10
|
+
} from '@owlmeans/payment'
|
|
11
|
+
import type {
|
|
12
|
+
CancellationReceipt, ConsumerRightsPolicy, DeclarationReceipt, PerformanceConsentView, PurchaseView,
|
|
13
|
+
SubscriptionStartView, WithdrawalCandidate, WithdrawalReceipt,
|
|
14
|
+
} from '@owlmeans/payment'
|
|
15
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
16
|
+
import { CONSUMER_RIGHTS_SERVICE, STRIPE_PAYGATE_ALIAS } from '../consts.js'
|
|
17
|
+
import { findPlan } from '../plan.js'
|
|
18
|
+
import { CONSUMER_RIGHTS_RESOURCE_MAKERS } from '../resource.js'
|
|
19
|
+
import {
|
|
20
|
+
billingProfiles, compact, conditionalSet, consumerConsents, consumerDeclarations, consumerEvents, consumerMailConfig,
|
|
21
|
+
errorText, observer, paygateCustomers, payment, purchases, stripeClient, subscriptions,
|
|
22
|
+
} from '../utils.js'
|
|
23
|
+
import { normalizeContractRef, normalizeEmail } from './format.js'
|
|
24
|
+
import { planTitleOf, sendConsumerMail, traderOf } from './mail.js'
|
|
25
|
+
import { originFields } from './origin.js'
|
|
26
|
+
import {
|
|
27
|
+
emailMatches, entitlingStripeSubscription, lockProfile, patchPurchase, profileViewOf, purchaseRefOf,
|
|
28
|
+
purchaseViewOf, recordEvent, unconsentedWindows, unlockProfile, windowOpen,
|
|
29
|
+
} from './records.js'
|
|
30
|
+
import { reconcileConsumerRights } from './reconcile.js'
|
|
31
|
+
import { computeWithdrawal, executeWithdrawal } from './withdrawal.js'
|
|
32
|
+
import type { WithdrawalComputation, WithdrawalExecution } from './withdrawal.js'
|
|
33
|
+
import type {
|
|
34
|
+
Config, ConsumerConsentRecord, ConsumerDeclarationRecord, ConsumerMailRenderer, ConsumerRightsOptions,
|
|
35
|
+
ConsumerRightsService, Context, PaymentSubscriptionRecord, PurchaseRecord, UsageMeter,
|
|
36
|
+
} from '../types.js'
|
|
37
|
+
|
|
38
|
+
const latestDeadline = (items: Array<{ deadline?: Date | null }>): Date | undefined => {
|
|
39
|
+
const times = items.map(item => item.deadline != null ? new Date(item.deadline).getTime() : Number.NaN)
|
|
40
|
+
.filter(time => !Number.isNaN(time))
|
|
41
|
+
|
|
42
|
+
return times.length > 0 ? new Date(Math.max(...times)) : undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const requirePolicy = async (ctx: ApiContext): Promise<ConsumerRightsPolicy> => {
|
|
46
|
+
const policy = await payment(ctx).consumerRightsPolicy()
|
|
47
|
+
if (policy == null) {
|
|
48
|
+
throw new ConsumerRightsError('policy:none')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return policy
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Only what a person typed, echoed as the receipt's content — never enriched with a match. */
|
|
55
|
+
const contentOf = (fields: Record<string, string | undefined | null>): Record<string, string> =>
|
|
56
|
+
Object.fromEntries(Object.entries(fields).filter((entry): entry is [string, string] =>
|
|
57
|
+
typeof entry[1] === 'string' && entry[1].trim() !== ''))
|
|
58
|
+
|
|
59
|
+
const publicReceipt = (declaration: ConsumerDeclarationRecord, content: Record<string, string>, mailed: boolean): DeclarationReceipt => ({
|
|
60
|
+
declarationId: declaration.id as string,
|
|
61
|
+
receivedAt: new Date(declaration.receivedAt),
|
|
62
|
+
content,
|
|
63
|
+
mailed,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
/** An e-mail value safe to prefill a form with. */
|
|
67
|
+
const prefillEmail = (email: string | undefined): string | undefined =>
|
|
68
|
+
email != null && /^[^\s@]+@[^\s@]+$/.test(email) ? email : undefined
|
|
69
|
+
|
|
70
|
+
/** A contract a person quoted — a contract reference, else an invoice number. */
|
|
71
|
+
const purchaseByContract = async (ctx: ApiContext, contract: string | undefined): Promise<PurchaseRecord | null> => {
|
|
72
|
+
if (contract == null || contract.trim() === '') {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
const reference = normalizeContractRef(contract)
|
|
76
|
+
|
|
77
|
+
return await purchases(ctx).load({ contractRef: reference })
|
|
78
|
+
?? await purchases(ctx).load({ invoiceNumber: contract.trim() })
|
|
79
|
+
?? await purchases(ctx).load({ invoiceNumber: contract.trim().toUpperCase() })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Public matching: the quoted contract, and an e-mail that belongs to it. */
|
|
83
|
+
const matchPublicPurchase = async (ctx: ApiContext, contract: string | undefined, email: string): Promise<PurchaseRecord | null> => {
|
|
84
|
+
const purchase = await purchaseByContract(ctx, contract)
|
|
85
|
+
|
|
86
|
+
return purchase != null && await emailMatches(ctx, purchase, email) ? purchase : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The one organization whose paygate customer uses this e-mail — none when several do. */
|
|
90
|
+
const entityByEmail = async (ctx: ApiContext, email: string): Promise<string | null> => {
|
|
91
|
+
const wanted = normalizeEmail(email)
|
|
92
|
+
if (wanted === '') {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
// Case-insensitive (`$ilike`, its `%`/`_`/`\` wildcards escaped): a paygate keeps the e-mail as typed.
|
|
96
|
+
const pattern = wanted.replace(/[\\%_]/g, match => `\\${match}`)
|
|
97
|
+
const { items } = await paygateCustomers(ctx).list({ paygate: STRIPE_PAYGATE_ALIAS, email: { $ilike: pattern } }, { size: 20 })
|
|
98
|
+
const entities = [...new Set(items.filter(item => item.deletedAt == null && item.entityId != null).map(item => item.entityId as string))]
|
|
99
|
+
|
|
100
|
+
return entities.length === 1 ? entities[0] : null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ConsumerRightsInternals {
|
|
104
|
+
readonly managed: boolean
|
|
105
|
+
meter: () => UsageMeter | null
|
|
106
|
+
stripe: (ctx: ApiContext) => Promise<Stripe>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Who hands a registration its options: the application itself, or the gateway on its behalf. */
|
|
110
|
+
export type ConsumerRightsRegistrar = 'application' | 'gateway'
|
|
111
|
+
|
|
112
|
+
/** What a later registration applies to a service already registered. */
|
|
113
|
+
type Plumbing = Pick<ConsumerRightsOptions, 'manage' | 'usage' | 'stripe'>
|
|
114
|
+
|
|
115
|
+
/** The late-configuration seam of every service `makeConsumerRightsService` made — module-private. */
|
|
116
|
+
const plumbers = new WeakMap<object, (plumbing: Plumbing, from: ConsumerRightsRegistrar) => void>()
|
|
117
|
+
|
|
118
|
+
/** Run the consumer-rights observers of one act; a throw is recorded for `reconcile` to retry. */
|
|
119
|
+
export const runConsumerObservers = async (
|
|
120
|
+
ctx: ApiContext, family: 'consent' | 'withdrawal' | 'cancellation', recordId: string, entityId: string | undefined,
|
|
121
|
+
run: () => Promise<void>,
|
|
122
|
+
): Promise<boolean> => {
|
|
123
|
+
const recordKind = family === 'consent' ? 'consent' as const : 'declaration' as const
|
|
124
|
+
try {
|
|
125
|
+
await run()
|
|
126
|
+
await recordEvent(ctx, { recordId, recordKind, entityId, action: 'observers', step: family, ok: true })
|
|
127
|
+
|
|
128
|
+
return true
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error(`[payment] ${family} observers of "${recordId}" failed; reconcile retries them`, error)
|
|
131
|
+
await recordEvent(ctx, { recordId, recordKind, entityId, action: 'observers', step: family, ok: false, error: errorText(error) })
|
|
132
|
+
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Tell the withdrawal observers — after the records and the paygate steps. */
|
|
138
|
+
export const notifyWithdrawal = async (
|
|
139
|
+
ctx: ApiContext, declaration: ConsumerDeclarationRecord, purchase: PurchaseRecord, status: WithdrawalStatus,
|
|
140
|
+
computation: Pick<WithdrawalComputation, 'reading' | 'deducted' | 'unitsReturned' | 'netMinor' | 'refundMinor'> | null,
|
|
141
|
+
execution: WithdrawalExecution | null,
|
|
142
|
+
): Promise<boolean> => await runConsumerObservers(ctx, 'withdrawal', declaration.id as string, purchase.entityId, async () => {
|
|
143
|
+
await observer(ctx).propagateWithdrawal({
|
|
144
|
+
withdrawalId: declaration.id as string,
|
|
145
|
+
eventKey: `withdrawal:${declaration.id as string}`,
|
|
146
|
+
entityId: purchase.entityId,
|
|
147
|
+
channel: declaration.channel,
|
|
148
|
+
purchase: purchaseRefOf(purchase),
|
|
149
|
+
declaredAt: new Date(declaration.receivedAt),
|
|
150
|
+
status,
|
|
151
|
+
refund: compact({
|
|
152
|
+
amountMinor: execution?.refundedMinor ?? 0,
|
|
153
|
+
currency: purchase.currency,
|
|
154
|
+
netMinor: computation?.netMinor,
|
|
155
|
+
refundId: execution?.refundId,
|
|
156
|
+
creditNoteId: execution?.creditNoteId,
|
|
157
|
+
}) as { amountMinor: number, currency: string },
|
|
158
|
+
units: computation != null
|
|
159
|
+
? { granted: computation.reading.granted, used: computation.deducted, returned: computation.unitsReturned } : null,
|
|
160
|
+
subscriptionCanceled: execution?.subscriptionCanceled === true,
|
|
161
|
+
}, ctx)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
/** Tell the cancellation observers. */
|
|
165
|
+
export const notifyCancellation = async (
|
|
166
|
+
ctx: ApiContext, declaration: ConsumerDeclarationRecord, status: CancellationStatus,
|
|
167
|
+
): Promise<boolean> => await runConsumerObservers(ctx, 'cancellation', declaration.id as string, declaration.entityId ?? undefined, async () => {
|
|
168
|
+
await observer(ctx).propagateCancellation(compact({
|
|
169
|
+
cancellationId: declaration.id as string,
|
|
170
|
+
eventKey: `cancellation:${declaration.id as string}`,
|
|
171
|
+
entityId: declaration.entityId ?? undefined,
|
|
172
|
+
matched: declaration.matched,
|
|
173
|
+
channel: declaration.channel,
|
|
174
|
+
kind: declaration.cancellationKind ?? CancellationKind.Ordinary,
|
|
175
|
+
status,
|
|
176
|
+
subscriptionId: declaration.subscriptionId ?? undefined,
|
|
177
|
+
effectiveAt: declaration.effectiveAt != null ? new Date(declaration.effectiveAt) : undefined,
|
|
178
|
+
declaredAt: new Date(declaration.receivedAt),
|
|
179
|
+
}) as Parameters<ReturnType<typeof observer>['propagateCancellation']>[0], ctx)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
/** Tell the consent observers. */
|
|
183
|
+
export const notifyConsent = async (ctx: ApiContext, consent: ConsumerConsentRecord): Promise<boolean> =>
|
|
184
|
+
await runConsumerObservers(ctx, 'consent', consent.id as string, consent.entityId, async () => {
|
|
185
|
+
await observer(ctx).propagateConsent(compact({
|
|
186
|
+
kind: consent.kind, consentId: consent.id as string, entityId: consent.entityId, profileId: consent.profileId,
|
|
187
|
+
purchaseIds: [...consent.purchaseIds], planSku: consent.planSku, textVersion: consent.textVersion,
|
|
188
|
+
language: consent.language, decidedAt: new Date(consent.decidedAt),
|
|
189
|
+
expiresAt: consent.expiresAt != null ? new Date(consent.expiresAt) : undefined,
|
|
190
|
+
eventKey: `consent:${consent.id as string}`,
|
|
191
|
+
}) as Parameters<ReturnType<typeof observer>['propagateConsent']>[0], ctx)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Schedule an ordinary cancellation at the paygate: at the period end (`cancel_at_period_end`), or
|
|
196
|
+
* at a later boundary (`cancel_at`, no proration). Recorded as a `cancel-scheduled` event.
|
|
197
|
+
*/
|
|
198
|
+
export const scheduleCancellation = async (
|
|
199
|
+
ctx: ApiContext, stripe: Stripe, declaration: ConsumerDeclarationRecord, row: PaymentSubscriptionRecord,
|
|
200
|
+
attempt: number = 0,
|
|
201
|
+
): Promise<boolean> => {
|
|
202
|
+
const id = declaration.id as string
|
|
203
|
+
const effectiveAt = declaration.effectiveAt != null ? new Date(declaration.effectiveAt) : null
|
|
204
|
+
const atPeriodEnd = effectiveAt == null || row.periodEnd == null
|
|
205
|
+
|| effectiveAt.getTime() === new Date(row.periodEnd).getTime()
|
|
206
|
+
const details = { comment: `cancellation:${id}` }
|
|
207
|
+
try {
|
|
208
|
+
await stripe.subscriptions.update(row.externalId, atPeriodEnd
|
|
209
|
+
? { cancel_at_period_end: true, cancellation_details: details }
|
|
210
|
+
: { cancel_at: Math.floor((effectiveAt as Date).getTime() / 1000), proration_behavior: 'none', cancellation_details: details },
|
|
211
|
+
{ idempotencyKey: attempt > 0 ? `cancellation:${id}:schedule:${attempt}` : `cancellation:${id}:schedule` })
|
|
212
|
+
await recordEvent(ctx, {
|
|
213
|
+
recordId: id, recordKind: 'declaration', entityId: row.entityId, action: 'cancel-scheduled', ok: true,
|
|
214
|
+
externalId: row.externalId, detail: JSON.stringify({ atPeriodEnd, effectiveAt: effectiveAt?.toISOString() }),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
return true
|
|
218
|
+
} catch (error) {
|
|
219
|
+
await recordEvent(ctx, {
|
|
220
|
+
recordId: id, recordKind: 'declaration', entityId: row.entityId, action: 'cancel-scheduled', ok: false,
|
|
221
|
+
externalId: row.externalId, error: errorText(error),
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The consumer-rights service (`CONSUMER_RIGHTS_SERVICE`): the billing profile and its lock,
|
|
230
|
+
* purchases and their withdrawal windows, performance consent and subscription start requests,
|
|
231
|
+
* the withdrawal and cancellation functions with their paygate steps, the durable-medium mails,
|
|
232
|
+
* and `reconcile`. Unmanaged (`manage: false`) it still reads, records consents and asserts them;
|
|
233
|
+
* withdrawing and cancelling need the paygate.
|
|
234
|
+
*
|
|
235
|
+
* A lazy service, like the completion observer: `useMeter` / `useMailRenderer` work while the
|
|
236
|
+
* application is still being wired, before the context initializes.
|
|
237
|
+
*/
|
|
238
|
+
export const makeConsumerRightsService = (
|
|
239
|
+
alias: string = CONSUMER_RIGHTS_SERVICE, opts: ConsumerRightsOptions = {},
|
|
240
|
+
): ConsumerRightsService => {
|
|
241
|
+
// `manage` by precedence: the application's own, else the gateway's, else managed.
|
|
242
|
+
let manageOwn: boolean | undefined = opts.manage
|
|
243
|
+
let manageGateway: boolean | undefined
|
|
244
|
+
const isManaged = (): boolean => (manageOwn ?? manageGateway) !== false
|
|
245
|
+
let meter: UsageMeter | null = opts.usage ?? null
|
|
246
|
+
let stripeFactory = opts.stripe
|
|
247
|
+
let renderer: ConsumerMailRenderer | null = null
|
|
248
|
+
const stripeOf = async (ctx: ApiContext): Promise<Stripe> =>
|
|
249
|
+
stripeFactory != null ? await stripeFactory(ctx) : await stripeClient(ctx)
|
|
250
|
+
const internals: ConsumerRightsInternals = {
|
|
251
|
+
get managed() { return isManaged() }, meter: () => meter, stripe: stripeOf,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const service: ConsumerRightsService = createLazyService<ConsumerRightsService>(alias, {
|
|
255
|
+
get managed() { return isManaged() },
|
|
256
|
+
|
|
257
|
+
policy: async () => await payment(service.assertCtx() as unknown as ApiContext).consumerRightsPolicy(),
|
|
258
|
+
|
|
259
|
+
profile: async entityId => {
|
|
260
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
261
|
+
const record = await billingProfiles(ctx).byEntity(entityId)
|
|
262
|
+
|
|
263
|
+
return record != null ? profileViewOf(record, await payment(ctx).consumerRightsPolicy()) : null
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
lock: async (entityId, country, source, lockOpts = {}) => {
|
|
267
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
268
|
+
const policy = await payment(ctx).consumerRightsPolicy()
|
|
269
|
+
const { record } = await lockProfile(ctx, policy, { ...lockOpts, entityId, country, source })
|
|
270
|
+
|
|
271
|
+
return profileViewOf(record, policy)
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
unlock: async (entityId, unlockOpts = {}) => {
|
|
275
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
276
|
+
const record = await unlockProfile(ctx, entityId, unlockOpts)
|
|
277
|
+
|
|
278
|
+
return record != null ? profileViewOf(record, await payment(ctx).consumerRightsPolicy()) : null
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
purchases: async (entityId, listOpts = {}) => {
|
|
282
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
283
|
+
const at = listOpts.at ?? new Date()
|
|
284
|
+
const { items } = await purchases(ctx).list({ entityId }, {
|
|
285
|
+
size: 200, sort: [{ field: 'purchasedAt', order: 'desc' }],
|
|
286
|
+
})
|
|
287
|
+
const views: PurchaseView[] = []
|
|
288
|
+
for (const purchase of items) {
|
|
289
|
+
const open = windowOpen(purchase, at)
|
|
290
|
+
if (listOpts.open === true && !open) continue
|
|
291
|
+
let withdrawable = open
|
|
292
|
+
if (open && meter != null && purchase.kind === PurchaseKind.TopUp) {
|
|
293
|
+
try {
|
|
294
|
+
withdrawable = (await computeWithdrawal(ctx, meter, purchase, at)).refundMinor > 0
|
|
295
|
+
} catch (error) {
|
|
296
|
+
console.warn(`[payment] usage of "${purchase.purchaseId}" unreadable`, error)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
views.push(purchaseViewOf(purchase, withdrawable))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return views
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
consentView: async (entityId, at = new Date()) => {
|
|
306
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
307
|
+
const policy = await requirePolicy(ctx)
|
|
308
|
+
const profile = await billingProfiles(ctx).byEntity(entityId)
|
|
309
|
+
const windows = policy.mechanisms.performanceConsent ? await unconsentedWindows(ctx, entityId, at) : []
|
|
310
|
+
const language = profile?.language ?? windows[0]?.language ?? policy.defaultLanguage
|
|
311
|
+
const deadline = latestDeadline(windows)
|
|
312
|
+
const view: PerformanceConsentView = {
|
|
313
|
+
required: windows.length > 0,
|
|
314
|
+
region: profile?.region ?? windows[0]?.region ?? null,
|
|
315
|
+
country: profile?.country ?? windows[0]?.country ?? null,
|
|
316
|
+
language,
|
|
317
|
+
trader: traderOf(ctx, await consumerMailConfig(ctx)).name,
|
|
318
|
+
textVersion: policy.textVersion,
|
|
319
|
+
copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
|
|
320
|
+
links: linksOf(policy, language),
|
|
321
|
+
purchases: windows.map(window => purchaseViewOf(window, true)),
|
|
322
|
+
...(deadline != null ? { deadline } : {}),
|
|
323
|
+
at,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return view
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
recordConsent: async (subject, body, origin) => {
|
|
330
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
331
|
+
const policy = await requirePolicy(ctx)
|
|
332
|
+
if (!policy.mechanisms.performanceConsent) {
|
|
333
|
+
throw new ConsumerRightsError('mechanism:performance-consent')
|
|
334
|
+
}
|
|
335
|
+
const at = new Date()
|
|
336
|
+
const windows = await unconsentedWindows(ctx, subject.entityId, at)
|
|
337
|
+
const covered = windows.filter(window => body.purchaseIds.includes(window.purchaseId))
|
|
338
|
+
// A statement of another version, or one that saw none of what is open now, is asked again.
|
|
339
|
+
if (body.textVersion !== policy.textVersion || (covered.length === 0 && windows.length > 0)) {
|
|
340
|
+
throw new PerformanceConsentRequired({ pending: windows.length, ...(latestDeadline(windows) != null ? { deadline: latestDeadline(windows) } : {}) })
|
|
341
|
+
}
|
|
342
|
+
const trader = traderOf(ctx, await consumerMailConfig(ctx))
|
|
343
|
+
const deadline = latestDeadline(covered)
|
|
344
|
+
const consent = await consumerConsents(ctx).create(compact({
|
|
345
|
+
kind: ConsentKind.Performance,
|
|
346
|
+
entityId: subject.entityId,
|
|
347
|
+
profileId: subject.profileId,
|
|
348
|
+
name: subject.name,
|
|
349
|
+
email: prefillEmail(subject.email),
|
|
350
|
+
purchaseIds: covered.map(window => window.purchaseId),
|
|
351
|
+
textVersion: policy.textVersion,
|
|
352
|
+
copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
|
|
353
|
+
language: body.language,
|
|
354
|
+
uiLanguage: body.uiLanguage,
|
|
355
|
+
trader: trader.name,
|
|
356
|
+
text: consentStatementOf(body.language, ConsentKind.Performance, { trader: trader.name }),
|
|
357
|
+
links: linksOf(policy, body.language),
|
|
358
|
+
deadline,
|
|
359
|
+
decidedAt: at,
|
|
360
|
+
...originFields(origin),
|
|
361
|
+
}) as ConsumerConsentRecord)
|
|
362
|
+
for (const window of covered) {
|
|
363
|
+
await conditionalSet(purchases(ctx), { purchaseId: window.purchaseId, consentedAt: null }, {
|
|
364
|
+
consentedAt: at, consentId: consent.id, updatedAt: at,
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
const mailed = covered.length > 0 ? await sendConsumerMail(ctx, policy, 'consent', consent.id as string) : false
|
|
368
|
+
await notifyConsent(ctx, consent)
|
|
369
|
+
|
|
370
|
+
return { consentId: consent.id as string, consentedAt: at, purchaseIds: [...consent.purchaseIds], mailed }
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
assertConsent: async (entityId, at = new Date()) => {
|
|
374
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
375
|
+
const policy = await payment(ctx).consumerRightsPolicy()
|
|
376
|
+
if (policy?.mechanisms.performanceConsent !== true) {
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
const windows = await unconsentedWindows(ctx, entityId, at)
|
|
380
|
+
if (windows.length > 0) {
|
|
381
|
+
const deadline = latestDeadline(windows)
|
|
382
|
+
throw new PerformanceConsentRequired({ pending: windows.length, ...(deadline != null ? { deadline } : {}) })
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
startView: async (entityId, planSku, viewOpts = {}) => {
|
|
387
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
388
|
+
const policy = await requirePolicy(ctx)
|
|
389
|
+
const profile = await billingProfiles(ctx).byEntity(entityId)
|
|
390
|
+
const language = viewOpts.language ?? profile?.language ?? policy.defaultLanguage
|
|
391
|
+
const view: SubscriptionStartView = {
|
|
392
|
+
required: policy.mechanisms.subscriptionStart
|
|
393
|
+
&& (profile == null || inScope(profile.region, profile.country, policy)),
|
|
394
|
+
planSku,
|
|
395
|
+
language,
|
|
396
|
+
trader: traderOf(ctx, await consumerMailConfig(ctx)).name,
|
|
397
|
+
textVersion: policy.textVersion,
|
|
398
|
+
copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
|
|
399
|
+
links: linksOf(policy, language),
|
|
400
|
+
region: profile?.region ?? null,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return view
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
recordStartRequest: async (subject, body, origin, startOpts = {}) => {
|
|
407
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
408
|
+
const policy = await requirePolicy(ctx)
|
|
409
|
+
if (!policy.mechanisms.subscriptionStart) {
|
|
410
|
+
throw new ConsumerRightsError('mechanism:subscription-start')
|
|
411
|
+
}
|
|
412
|
+
if (body.textVersion !== policy.textVersion) {
|
|
413
|
+
throw new SubscriptionStartRequired(body.planSku)
|
|
414
|
+
}
|
|
415
|
+
if (await findPlan(ctx, body.planSku) == null) {
|
|
416
|
+
throw new UnknownPlan(body.planSku)
|
|
417
|
+
}
|
|
418
|
+
const at = new Date()
|
|
419
|
+
const expiresAt = new Date(at.getTime() + (policy.startRequestTtlSeconds ?? 3600) * 1000)
|
|
420
|
+
const trader = traderOf(ctx, await consumerMailConfig(ctx))
|
|
421
|
+
const planName = startOpts.plan ?? await planTitleOf(ctx, body.planSku, body.language)
|
|
422
|
+
const consent = await consumerConsents(ctx).create(compact({
|
|
423
|
+
kind: ConsentKind.SubscriptionStart,
|
|
424
|
+
entityId: subject.entityId,
|
|
425
|
+
profileId: subject.profileId,
|
|
426
|
+
name: subject.name,
|
|
427
|
+
email: prefillEmail(subject.email),
|
|
428
|
+
purchaseIds: [],
|
|
429
|
+
planSku: body.planSku,
|
|
430
|
+
planName,
|
|
431
|
+
textVersion: policy.textVersion,
|
|
432
|
+
copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
|
|
433
|
+
language: body.language,
|
|
434
|
+
trader: trader.name,
|
|
435
|
+
text: consentStatementOf(body.language, ConsentKind.SubscriptionStart, { trader: trader.name, plan: planName }),
|
|
436
|
+
links: linksOf(policy, body.language),
|
|
437
|
+
decidedAt: at,
|
|
438
|
+
expiresAt,
|
|
439
|
+
...originFields(origin),
|
|
440
|
+
}) as ConsumerConsentRecord)
|
|
441
|
+
await sendConsumerMail(ctx, policy, 'start', consent.id as string)
|
|
442
|
+
await notifyConsent(ctx, consent)
|
|
443
|
+
|
|
444
|
+
return { startRequestId: consent.id as string, requestedAt: at, expiresAt }
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
assertStartRequest: async (entityId, planSku, startRequestId) => {
|
|
448
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
449
|
+
const policy = await payment(ctx).consumerRightsPolicy()
|
|
450
|
+
if (policy?.mechanisms.subscriptionStart !== true) {
|
|
451
|
+
return null
|
|
452
|
+
}
|
|
453
|
+
const profile = await billingProfiles(ctx).byEntity(entityId)
|
|
454
|
+
// Only an organization already locked outside the territories goes without one: a country
|
|
455
|
+
// picked before checkout may differ from the address typed at the paygate.
|
|
456
|
+
if (profile != null && !inScope(profile.region, profile.country, policy)) {
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
const record = startRequestId != null && startRequestId !== ''
|
|
460
|
+
? await consumerConsents(ctx).load(startRequestId).catch(() => null) : null
|
|
461
|
+
if (record == null || record.kind !== ConsentKind.SubscriptionStart || record.entityId !== entityId
|
|
462
|
+
|| record.planSku !== planSku || record.textVersion !== policy.textVersion
|
|
463
|
+
|| record.expiresAt == null || new Date(record.expiresAt).getTime() <= Date.now()) {
|
|
464
|
+
throw new SubscriptionStartRequired(planSku)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return record
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
withdrawalCandidates: async (entityId, subject = {}) => {
|
|
471
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
472
|
+
const policy = await requirePolicy(ctx)
|
|
473
|
+
const profile = await billingProfiles(ctx).byEntity(entityId)
|
|
474
|
+
const at = new Date()
|
|
475
|
+
const open = policy.mechanisms.withdrawal
|
|
476
|
+
? (await purchases(ctx).list({
|
|
477
|
+
entityId, inScope: true, withdrawnAt: null, refundedAt: null, deadline: { $gt: at },
|
|
478
|
+
}, { size: 100, sort: [{ field: 'purchasedAt', order: 'desc' }] })).items
|
|
479
|
+
: []
|
|
480
|
+
const candidates: WithdrawalCandidate[] = []
|
|
481
|
+
for (const purchase of open) {
|
|
482
|
+
let estimate: WithdrawalCandidate['estimate'] = null
|
|
483
|
+
if (meter != null) {
|
|
484
|
+
try {
|
|
485
|
+
estimate = (await computeWithdrawal(ctx, meter, purchase, at)).estimate
|
|
486
|
+
} catch (error) {
|
|
487
|
+
console.warn(`[payment] withdrawal estimate of "${purchase.purchaseId}" failed`, error)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// Credits fully used after consent: the right has expired, nothing would be reimbursed.
|
|
491
|
+
if (estimate != null && purchase.kind === PurchaseKind.TopUp && estimate.refundMinor === 0) continue
|
|
492
|
+
candidates.push({
|
|
493
|
+
purchaseId: purchase.purchaseId,
|
|
494
|
+
contractRef: purchase.contractRef,
|
|
495
|
+
kind: purchase.kind,
|
|
496
|
+
purchasedAt: new Date(purchase.purchasedAt),
|
|
497
|
+
deadline: new Date(purchase.deadline as Date),
|
|
498
|
+
amountTotalMinor: purchase.amountTotalMinor,
|
|
499
|
+
currency: purchase.currency,
|
|
500
|
+
estimate,
|
|
501
|
+
automatic: policy.mechanisms.automaticRefunds && meter != null && isManaged(),
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
const language = profile?.language ?? open[0]?.language ?? policy.defaultLanguage
|
|
505
|
+
|
|
506
|
+
return compact({
|
|
507
|
+
candidates, language, links: linksOf(policy, language),
|
|
508
|
+
name: subject.name != null && subject.name.trim() !== '' ? subject.name : undefined,
|
|
509
|
+
email: prefillEmail(subject.email),
|
|
510
|
+
}) as Awaited<ReturnType<ConsumerRightsService['withdrawalCandidates']>>
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
withdraw: async (subject, body, origin) => {
|
|
514
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
515
|
+
const policy = await requirePolicy(ctx)
|
|
516
|
+
if (!policy.mechanisms.withdrawal) {
|
|
517
|
+
throw new ConsumerRightsError('mechanism:withdrawal')
|
|
518
|
+
}
|
|
519
|
+
if (!isManaged()) {
|
|
520
|
+
throw new PaygateError('unmanaged')
|
|
521
|
+
}
|
|
522
|
+
const at = new Date()
|
|
523
|
+
const channel = subject == null ? DeclarationChannel.Public : subject.channel ?? DeclarationChannel.InApp
|
|
524
|
+
const disclose = channel === DeclarationChannel.InApp
|
|
525
|
+
|
|
526
|
+
let purchase: PurchaseRecord | null = null
|
|
527
|
+
if (subject != null) {
|
|
528
|
+
purchase = body.purchaseId != null ? await purchases(ctx).byPurchaseId(body.purchaseId)
|
|
529
|
+
: await purchaseByContract(ctx, body.contractRef)
|
|
530
|
+
if (purchase != null && purchase.entityId !== subject.entityId) purchase = null
|
|
531
|
+
} else {
|
|
532
|
+
purchase = await matchPublicPurchase(ctx, body.contractRef, body.email)
|
|
533
|
+
}
|
|
534
|
+
if (disclose) {
|
|
535
|
+
if (purchase == null) throw new WithdrawalUnavailable(WithdrawalUnavailableReason.Unknown)
|
|
536
|
+
if (!purchase.inScope) throw new WithdrawalUnavailable(WithdrawalUnavailableReason.NotInScope)
|
|
537
|
+
if (purchase.withdrawnAt == null && purchase.refundedAt != null) {
|
|
538
|
+
throw new WithdrawalUnavailable(WithdrawalUnavailableReason.Withdrawn)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const content = contentOf({
|
|
542
|
+
name: body.name, contract: body.contractRef ?? (disclose ? purchase?.contractRef : undefined), email: body.email,
|
|
543
|
+
})
|
|
544
|
+
const profile = purchase != null ? await billingProfiles(ctx).byEntity(purchase.entityId) : null
|
|
545
|
+
const language = body.language ?? purchase?.language ?? profile?.language ?? policy.defaultLanguage
|
|
546
|
+
const declarationBase = {
|
|
547
|
+
kind: DeclarationKind.Withdrawal, channel,
|
|
548
|
+
entityId: purchase?.entityId ?? subject?.entityId,
|
|
549
|
+
purchaseId: purchase?.purchaseId,
|
|
550
|
+
subscriptionId: purchase?.subscriptionId ?? undefined,
|
|
551
|
+
contractRef: body.contractRef ?? purchase?.contractRef,
|
|
552
|
+
name: body.name, email: body.email, language,
|
|
553
|
+
textVersion: policy.textVersion, copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
|
|
554
|
+
receivedAt: at, matched: purchase != null, profileId: subject?.profileId, ...originFields(origin),
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// A repeated declaration of a contract already withdrawn from: recorded, answered with the original.
|
|
558
|
+
if (purchase?.withdrawnAt != null) {
|
|
559
|
+
const original = purchase.withdrawalId != null ? await consumerDeclarations(ctx).load(purchase.withdrawalId).catch(() => null) : null
|
|
560
|
+
const repeated = await consumerDeclarations(ctx).create(compact({
|
|
561
|
+
...declarationBase, duplicateOf: original?.id, status: original?.status ?? WithdrawalStatus.Received,
|
|
562
|
+
}) as ConsumerDeclarationRecord)
|
|
563
|
+
if (!disclose) {
|
|
564
|
+
return publicReceipt(repeated, content, false) as WithdrawalReceipt
|
|
565
|
+
}
|
|
566
|
+
const answered = original ?? repeated
|
|
567
|
+
|
|
568
|
+
return receiptOf(answered, content, false, await executedStatusOf(ctx, answered))
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
let status: WithdrawalStatus
|
|
572
|
+
let computation: WithdrawalComputation | null = null
|
|
573
|
+
if (purchase == null || !purchase.inScope || purchase.refundedAt != null) {
|
|
574
|
+
status = WithdrawalStatus.Received
|
|
575
|
+
} else if (purchase.deadline == null || at.getTime() >= new Date(purchase.deadline).getTime()) {
|
|
576
|
+
status = WithdrawalStatus.Expired
|
|
577
|
+
} else if (meter != null && policy.mechanisms.automaticRefunds) {
|
|
578
|
+
try {
|
|
579
|
+
computation = await computeWithdrawal(ctx, meter, purchase, at)
|
|
580
|
+
} catch (error) {
|
|
581
|
+
await recordEvent(ctx, {
|
|
582
|
+
recordId: purchase.purchaseId, recordKind: 'purchase', entityId: purchase.entityId, action: 'meter', ok: false,
|
|
583
|
+
error: errorText(error),
|
|
584
|
+
})
|
|
585
|
+
}
|
|
586
|
+
status = computation != null ? WithdrawalStatus.Processing : WithdrawalStatus.Review
|
|
587
|
+
if (computation != null && purchase.kind === PurchaseKind.TopUp && computation.refundMinor === 0) {
|
|
588
|
+
if (disclose) throw new WithdrawalUnavailable(WithdrawalUnavailableReason.Performed)
|
|
589
|
+
computation = null
|
|
590
|
+
status = WithdrawalStatus.Review
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
status = WithdrawalStatus.Review
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const declaration = await consumerDeclarations(ctx).create(compact({
|
|
597
|
+
...declarationBase, status,
|
|
598
|
+
refundMinor: computation?.refundMinor, currency: computation != null ? purchase?.currency : undefined,
|
|
599
|
+
}) as ConsumerDeclarationRecord)
|
|
600
|
+
const withdrawalId = declaration.id as string
|
|
601
|
+
|
|
602
|
+
if (purchase != null && (status === WithdrawalStatus.Processing || status === WithdrawalStatus.Review)) {
|
|
603
|
+
// The window closes now; only one declaration of a purchase wins it.
|
|
604
|
+
const won = await conditionalSet(purchases(ctx), { purchaseId: purchase.purchaseId, withdrawnAt: null }, {
|
|
605
|
+
withdrawnAt: at, withdrawalId, updatedAt: at,
|
|
606
|
+
})
|
|
607
|
+
if (!won) {
|
|
608
|
+
const winner = await purchases(ctx).byPurchaseId(purchase.purchaseId)
|
|
609
|
+
await recordEvent(ctx, {
|
|
610
|
+
recordId: withdrawalId, recordKind: 'declaration', entityId: purchase.entityId, action: 'duplicate', ok: true,
|
|
611
|
+
detail: JSON.stringify({ of: winner?.withdrawalId }),
|
|
612
|
+
})
|
|
613
|
+
const original = winner?.withdrawalId != null ? await consumerDeclarations(ctx).load(winner.withdrawalId).catch(() => null) : null
|
|
614
|
+
|
|
615
|
+
const answered = original ?? declaration
|
|
616
|
+
|
|
617
|
+
return disclose
|
|
618
|
+
? receiptOf(answered, content, false, await executedStatusOf(ctx, answered))
|
|
619
|
+
: publicReceipt(declaration, content, false) as WithdrawalReceipt
|
|
620
|
+
}
|
|
621
|
+
purchase = { ...purchase, withdrawnAt: at, withdrawalId }
|
|
622
|
+
}
|
|
623
|
+
if (computation != null && purchase != null) {
|
|
624
|
+
await recordEvent(ctx, {
|
|
625
|
+
recordId: withdrawalId, recordKind: 'declaration', entityId: purchase.entityId, action: 'computed', ok: true,
|
|
626
|
+
amountMinor: computation.refundMinor, currency: purchase.currency,
|
|
627
|
+
detail: JSON.stringify({
|
|
628
|
+
reading: computation.reading, deducted: computation.deducted, netMinor: computation.netMinor,
|
|
629
|
+
unitsReturned: computation.unitsReturned, estimate: computation.estimate,
|
|
630
|
+
}),
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
const mailed = await sendConsumerMail(ctx, policy, 'withdrawal', withdrawalId)
|
|
634
|
+
|
|
635
|
+
let final: WithdrawalStatus = status
|
|
636
|
+
let execution: WithdrawalExecution | null = null
|
|
637
|
+
if (status === WithdrawalStatus.Processing && purchase != null && computation != null) {
|
|
638
|
+
execution = await executeWithdrawal(ctx, await stripeOf(ctx), declaration, purchase, computation)
|
|
639
|
+
final = execution.needsReview ? WithdrawalStatus.Review : execution.ok ? WithdrawalStatus.Refunded : WithdrawalStatus.Failed
|
|
640
|
+
}
|
|
641
|
+
if (purchase != null && (final === WithdrawalStatus.Refunded || final === WithdrawalStatus.Review)
|
|
642
|
+
&& (status === WithdrawalStatus.Processing || status === WithdrawalStatus.Review)) {
|
|
643
|
+
await notifyWithdrawal(ctx, declaration, purchase, final, computation, execution)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!disclose) {
|
|
647
|
+
return publicReceipt(declaration, content, mailed) as WithdrawalReceipt
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return compact({
|
|
651
|
+
...receiptOf(declaration, content, mailed), status: final,
|
|
652
|
+
refundMinor: execution?.refundedMinor ?? computation?.refundMinor,
|
|
653
|
+
subscriptionCanceled: execution?.subscriptionCanceled,
|
|
654
|
+
}) as WithdrawalReceipt
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
cancel: async (subject, body, origin) => {
|
|
658
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
659
|
+
const policy = await requirePolicy(ctx)
|
|
660
|
+
if (!policy.mechanisms.cancellation) {
|
|
661
|
+
throw new ConsumerRightsError('mechanism:cancellation')
|
|
662
|
+
}
|
|
663
|
+
if (!isManaged()) {
|
|
664
|
+
throw new PaygateError('unmanaged')
|
|
665
|
+
}
|
|
666
|
+
const at = new Date()
|
|
667
|
+
const channel = subject == null ? DeclarationChannel.Public : subject.channel ?? DeclarationChannel.InApp
|
|
668
|
+
const disclose = channel === DeclarationChannel.InApp
|
|
669
|
+
|
|
670
|
+
let purchase: PurchaseRecord | null = null
|
|
671
|
+
let row: PaymentSubscriptionRecord | null = null
|
|
672
|
+
if (subject != null) {
|
|
673
|
+
if (body.subscriptionId != null) {
|
|
674
|
+
row = await subscriptions(ctx).byExternalId(body.subscriptionId, STRIPE_PAYGATE_ALIAS)
|
|
675
|
+
} else if (body.contractRef != null) {
|
|
676
|
+
purchase = await purchaseByContract(ctx, body.contractRef)
|
|
677
|
+
if (purchase != null && purchase.entityId === subject.entityId && purchase.subscriptionId != null) {
|
|
678
|
+
row = await subscriptions(ctx).byExternalId(purchase.subscriptionId, STRIPE_PAYGATE_ALIAS)
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
row = row != null && row.entityId === subject.entityId ? row : await entitlingStripeSubscription(ctx, subject.entityId)
|
|
682
|
+
} else {
|
|
683
|
+
purchase = await matchPublicPurchase(ctx, body.contractRef, body.email)
|
|
684
|
+
if (purchase?.subscriptionId != null) {
|
|
685
|
+
row = await subscriptions(ctx).byExternalId(purchase.subscriptionId, STRIPE_PAYGATE_ALIAS)
|
|
686
|
+
} else if (purchase == null) {
|
|
687
|
+
const entityId = await entityByEmail(ctx, body.email)
|
|
688
|
+
row = entityId != null ? await entitlingStripeSubscription(ctx, entityId) : null
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const live = row != null && !TERMINAL_STATUSES.includes(row.status) && ENTITLING_STATUSES.includes(row.status)
|
|
692
|
+
if (disclose && row == null) throw new CancellationUnavailable(CancellationUnavailableReason.NoSubscription)
|
|
693
|
+
if (disclose && !live) throw new CancellationUnavailable(CancellationUnavailableReason.Ended)
|
|
694
|
+
if (row != null && purchase == null) {
|
|
695
|
+
purchase = await purchases(ctx).load({ subscriptionId: row.externalId })
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const requested = body.effective === 'date' && body.date != null ? new Date(`${body.date}T00:00:00.000Z`) : null
|
|
699
|
+
const plan = row != null ? await findPlan(ctx, row.planSku) : null
|
|
700
|
+
let status: CancellationStatus
|
|
701
|
+
let effectiveAt: Date | undefined
|
|
702
|
+
if (body.kind === CancellationKind.Extraordinary) {
|
|
703
|
+
// For cause: recorded and left to an operator — the paygate is not touched.
|
|
704
|
+
status = CancellationStatus.Review
|
|
705
|
+
} else if (row != null && live && row.periodEnd != null) {
|
|
706
|
+
effectiveAt = cancellationEffectiveAt(new Date(row.periodEnd), plan?.recurring?.interval ?? 'month',
|
|
707
|
+
requested != null && !Number.isNaN(requested.getTime()) ? requested : null)
|
|
708
|
+
status = row.cancelAtPeriodEnd === true && effectiveAt.getTime() === new Date(row.periodEnd).getTime()
|
|
709
|
+
? CancellationStatus.AlreadyScheduled : CancellationStatus.Scheduled
|
|
710
|
+
} else {
|
|
711
|
+
status = CancellationStatus.Received
|
|
712
|
+
}
|
|
713
|
+
const profile = row != null ? await billingProfiles(ctx).byEntity(row.entityId) : null
|
|
714
|
+
const language = body.language ?? purchase?.language ?? profile?.language ?? policy.defaultLanguage
|
|
715
|
+
const declaration = await consumerDeclarations(ctx).create(compact({
|
|
716
|
+
kind: DeclarationKind.Cancellation, channel,
|
|
717
|
+
entityId: row?.entityId ?? subject?.entityId,
|
|
718
|
+
purchaseId: purchase?.purchaseId,
|
|
719
|
+
subscriptionId: row?.externalId,
|
|
720
|
+
contractRef: body.contractRef ?? purchase?.contractRef,
|
|
721
|
+
name: body.name, email: body.email,
|
|
722
|
+
cancellationKind: body.kind, reason: body.reason, effective: body.effective, requestedDate: body.date,
|
|
723
|
+
language, textVersion: policy.textVersion, copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
|
|
724
|
+
receivedAt: at, matched: row != null && live, profileId: subject?.profileId, status, effectiveAt,
|
|
725
|
+
...originFields(origin),
|
|
726
|
+
}) as ConsumerDeclarationRecord)
|
|
727
|
+
let final = status
|
|
728
|
+
if (status === CancellationStatus.Scheduled && row != null) {
|
|
729
|
+
final = await scheduleCancellation(ctx, await stripeOf(ctx), declaration, row) ? CancellationStatus.Scheduled : CancellationStatus.Received
|
|
730
|
+
}
|
|
731
|
+
if (purchase != null && effectiveAt != null) {
|
|
732
|
+
await patchPurchase(ctx, purchase.purchaseId, { cancellationId: declaration.id as string, cancelEffectiveAt: effectiveAt })
|
|
733
|
+
}
|
|
734
|
+
const mailed = await sendConsumerMail(ctx, policy, 'cancellation', declaration.id as string)
|
|
735
|
+
await notifyCancellation(ctx, declaration, final)
|
|
736
|
+
const content = contentOf({
|
|
737
|
+
name: body.name, contract: body.contractRef ?? (disclose ? purchase?.contractRef ?? row?.externalId : undefined),
|
|
738
|
+
email: body.email, kind: body.kind, reason: body.reason, date: body.effective === 'date' ? body.date : undefined,
|
|
739
|
+
})
|
|
740
|
+
if (!disclose) {
|
|
741
|
+
return publicReceipt(declaration, content, mailed) as CancellationReceipt
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return compact({
|
|
745
|
+
...publicReceipt(declaration, content, mailed), status: final, effectiveAt,
|
|
746
|
+
}) as CancellationReceipt
|
|
747
|
+
},
|
|
748
|
+
|
|
749
|
+
useMeter: next => { meter = next },
|
|
750
|
+
useMailRenderer: next => { renderer = next },
|
|
751
|
+
usageMeter: () => meter,
|
|
752
|
+
mailRenderer: () => renderer,
|
|
753
|
+
|
|
754
|
+
reconcile: async (reconcileOpts = {}) =>
|
|
755
|
+
await reconcileConsumerRights(service.assertCtx() as unknown as ApiContext, internals, reconcileOpts),
|
|
756
|
+
}, service => async () => {
|
|
757
|
+
service.initialized = true
|
|
758
|
+
const ctx = service.assertCtx() as unknown as ApiContext
|
|
759
|
+
void ctx.waitForInitialized?.().then(async () => { await bootWarnings(ctx, isManaged(), () => meter) })
|
|
760
|
+
.catch(error => { console.error('[payment] consumer-rights boot check failed', error) })
|
|
761
|
+
})
|
|
762
|
+
plumbers.set(service, (plumbing, from) => {
|
|
763
|
+
if (plumbing.manage != null) {
|
|
764
|
+
if (from === 'application') manageOwn = plumbing.manage
|
|
765
|
+
else manageGateway = plumbing.manage
|
|
766
|
+
}
|
|
767
|
+
if (plumbing.usage != null) meter = plumbing.usage
|
|
768
|
+
if (plumbing.stripe != null) stripeFactory = plumbing.stripe
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
return service
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** A withdrawal receipt from its declaration (in-app), `status` as executed so far. */
|
|
775
|
+
const receiptOf = (
|
|
776
|
+
declaration: ConsumerDeclarationRecord, content: Record<string, string>, mailed: boolean, status?: WithdrawalStatus,
|
|
777
|
+
): WithdrawalReceipt => compact({
|
|
778
|
+
...publicReceipt(declaration, content, mailed),
|
|
779
|
+
status: status ?? declaration.status as WithdrawalStatus,
|
|
780
|
+
refundMinor: declaration.refundMinor ?? undefined,
|
|
781
|
+
currency: declaration.currency ?? undefined,
|
|
782
|
+
}) as WithdrawalReceipt
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Where a withdrawal stands now: a `processing` declaration is `refunded` once its refund (and its
|
|
786
|
+
* subscription cancel) succeeded, `failed` after a failed attempt, else still `processing`.
|
|
787
|
+
*/
|
|
788
|
+
const executedStatusOf = async (ctx: ApiContext, declaration: ConsumerDeclarationRecord): Promise<WithdrawalStatus> => {
|
|
789
|
+
if (declaration.status !== WithdrawalStatus.Processing) {
|
|
790
|
+
return declaration.status as WithdrawalStatus
|
|
791
|
+
}
|
|
792
|
+
const id = declaration.id as string
|
|
793
|
+
const refunded = (declaration.refundMinor ?? 0) <= 0 || await consumerEvents(ctx).load({ recordId: id, action: 'refund', ok: true }) != null
|
|
794
|
+
if (refunded) {
|
|
795
|
+
return WithdrawalStatus.Refunded
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return await consumerEvents(ctx).load({ recordId: id, action: 'refund', ok: false }) != null
|
|
799
|
+
? WithdrawalStatus.Failed : WithdrawalStatus.Processing
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const bootWarnings = async (ctx: ApiContext, managed: boolean, meter: () => UsageMeter | null): Promise<void> => {
|
|
803
|
+
const policy = await payment(ctx).consumerRightsPolicy()
|
|
804
|
+
// An unmanaged process (a worker that asserts consent) neither mails nor refunds.
|
|
805
|
+
if (policy == null || !managed) {
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
const { mechanisms } = policy
|
|
809
|
+
const mailing = mechanisms.purchaseConfirmation || mechanisms.performanceConsent || mechanisms.subscriptionStart
|
|
810
|
+
|| mechanisms.withdrawal || mechanisms.cancellation
|
|
811
|
+
const mail = await consumerMailConfig(ctx)
|
|
812
|
+
if (mailing) {
|
|
813
|
+
const trader = mail?.trader
|
|
814
|
+
if (trader?.address == null || trader.email == null) {
|
|
815
|
+
console.warn('[payment] consumer rights: the trader has no postal address or e-mail — the legal mails and the '
|
|
816
|
+
+ 'withdrawal information go out without them')
|
|
817
|
+
}
|
|
818
|
+
const alias = mail?.alias ?? MAILER_SERVICE
|
|
819
|
+
if ((ctx as unknown as { hasService?: (alias: string) => boolean }).hasService?.(alias) !== true) {
|
|
820
|
+
console.warn(`[payment] consumer rights: no mailer "${alias}" — no durable-medium mail is sent`)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (managed && mechanisms.withdrawal && mechanisms.automaticRefunds && meter() == null) {
|
|
824
|
+
console.warn('[payment] consumer rights: no usage meter — every withdrawal is left to an operator (review)')
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Register the consumer-rights resources and service (each only when absent), or configure the
|
|
830
|
+
* service already registered — `from` says who asks. The order of the application's call and the
|
|
831
|
+
* gateway's is free: `usage` and `stripe` are installed whenever they come, and the application's
|
|
832
|
+
* explicit `manage` wins over the gateway's. The resources keep the first registration's aliases.
|
|
833
|
+
*/
|
|
834
|
+
export const registerConsumerRights = <C extends Config, T extends Context<C>>(
|
|
835
|
+
ctx: T, opts: ConsumerRightsOptions = {}, from: ConsumerRightsRegistrar = 'application',
|
|
836
|
+
): T => {
|
|
837
|
+
for (const [alias, maker] of CONSUMER_RIGHTS_RESOURCE_MAKERS) {
|
|
838
|
+
if (!ctx.hasResource(alias)) {
|
|
839
|
+
ctx.registerResource(maker(opts.dbAlias, opts.serviceAlias) as never)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
const alias = opts.alias ?? CONSUMER_RIGHTS_SERVICE
|
|
843
|
+
// A lazy service: reachable before the context initializes.
|
|
844
|
+
let service = ctx.hasService(alias) ? ctx.service<ConsumerRightsService>(alias) : null
|
|
845
|
+
if (service == null) {
|
|
846
|
+
service = makeConsumerRightsService(alias, { ...opts, manage: undefined, usage: undefined, stripe: undefined })
|
|
847
|
+
ctx.registerService(service)
|
|
848
|
+
}
|
|
849
|
+
const plumb = plumbers.get(service)
|
|
850
|
+
if (plumb != null) {
|
|
851
|
+
plumb(compact({ manage: opts.manage, usage: opts.usage, stripe: opts.stripe }), from)
|
|
852
|
+
} else if (opts.usage != null) {
|
|
853
|
+
// A service made elsewhere: only its public seam.
|
|
854
|
+
service.useMeter(opts.usage)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return ctx
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Register the consumer-rights resources and service, each only when absent — the gateway
|
|
862
|
+
* registration does too. Called before or after the gateway, it installs `usage` and `stripe` on
|
|
863
|
+
* the service and its explicit `manage` wins over the gateway's; `useMeter` and `useMailRenderer`
|
|
864
|
+
* work on the service at any time.
|
|
865
|
+
*/
|
|
866
|
+
export const appendConsumerRights = <C extends Config, T extends Context<C>>(
|
|
867
|
+
ctx: T, opts: ConsumerRightsOptions = {},
|
|
868
|
+
): T => registerConsumerRights(ctx, opts, 'application')
|